Files
Containr/app/frontend/src/lib/auth-client.ts
T
Tomas DvorakandDevin c16b32f56a feat: owner accounts and in-app platform settings
- First registered account becomes admin (users.is_admin, backfilled
  to earliest account); better-auth mirror path does the same
- Public registration closes after bootstrap; admin can reopen it via
  GET/PUT /api/v1/settings (admin-gated) or SIGNUP_ENABLED env
- In-app settings override env vars; secrets stored in app_settings,
  token values never returned by the API
- Saving a Cloudflare Tunnel token pulls and runs a managed
  cloudflared sidecar on the backend's networks; clearing removes it;
  startup restores it from saved settings
- group_add DOCKER_GID grants the non-root backend user Docker socket
  access; install.sh probes the in-container socket gid
- Settings page gains an owner-only Platform section (registration
  toggle, tunnel token, link to Cloudflare dashboard); sign-up page
  stays reachable while registration is open
- OpenAPI spec + generated types updated; manual user creation now
  requires admin

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-21 18:05:22 +02:00

240 lines
6.6 KiB
TypeScript

export type AuthUser = {
id: string;
email: string;
name: string;
image?: string | null;
};
export type AuthSession = {
id: string;
userId: string;
expiresAt: string;
};
export type AuthSessionPayload = {
user: AuthUser;
session: AuthSession;
};
export type AuthBootstrap = {
hasUsers: boolean;
userCount: number;
mode: 'register' | 'login';
providers: string[];
signupEnabled: boolean;
};
export class AuthError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'AuthError';
this.status = status;
}
}
const configuredAuthBase = ((import.meta.env.VITE_AUTH_URL as string | undefined) ?? '').trim();
const AUTH_BASE = (configuredAuthBase || `${window.location.origin}/api/auth`).replace(/\/$/, '');
export function getAuthBaseUrl(): string {
return AUTH_BASE;
}
export async function getAuthBootstrap(): Promise<AuthBootstrap> {
const configuredApiBase = ((import.meta.env.VITE_API_URL as string | undefined) ?? '').trim();
const apiBase = (configuredApiBase || window.location.origin).replace(/\/$/, '');
const normalizedApiBase = /\/api\/v1$/.test(apiBase) ? apiBase : `${apiBase}/api/v1`;
const response = await fetch(`${normalizedApiBase}/auth/bootstrap`, {
method: 'GET',
credentials: 'include',
});
const payload = (await response.json().catch(() => null)) as Record<string, unknown> | null;
if (!response.ok) {
const message =
(payload?.message as string | undefined) ??
(payload?.error as string | undefined) ??
`Auth bootstrap request failed with status ${response.status}`;
throw new AuthError(message, response.status);
}
const providers = Array.isArray(payload?.providers)
? payload.providers.filter((provider): provider is string => typeof provider === 'string')
: [];
return {
hasUsers: Boolean(payload?.has_users),
userCount: typeof payload?.user_count === 'number' ? payload.user_count : 0,
mode: payload?.mode === 'register' ? 'register' : 'login',
providers,
signupEnabled: Boolean(payload?.signup_enabled),
};
}
function normalizeSessionPayload(payload: unknown): AuthSessionPayload | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const objectPayload = payload as Record<string, unknown>;
const source = (objectPayload.data as Record<string, unknown> | undefined) ?? objectPayload;
const user = source.user as Record<string, unknown> | undefined;
const session = source.session as Record<string, unknown> | undefined;
if (!user || !session || typeof user.id !== 'string' || typeof user.email !== 'string' || typeof user.name !== 'string' || typeof session.id !== 'string' || typeof session.userId !== 'string' || typeof session.expiresAt !== 'string') {
return null;
}
return {
user: {
id: user.id,
email: user.email,
name: user.name,
image: typeof user.image === 'string' ? user.image : null,
},
session: {
id: session.id,
userId: session.userId,
expiresAt: session.expiresAt,
},
};
}
async function authRequest(path: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(`${AUTH_BASE}${path}`, {
...init,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
});
if (response.status === 204) {
return null;
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const objectPayload = payload as Record<string, unknown> | null;
const message =
(objectPayload?.message as string | undefined) ??
(objectPayload?.error as string | undefined) ??
`Auth request failed with status ${response.status}`;
throw new AuthError(message, response.status);
}
return payload;
}
function parseOAuthRedirectPayload(payload: unknown): { url: string; redirect: boolean } {
if (!payload || typeof payload !== 'object') {
throw new AuthError('Invalid OAuth response payload', 500);
}
const objectPayload = payload as Record<string, unknown>;
const source = (objectPayload.data as Record<string, unknown> | undefined) ?? objectPayload;
const url = source.url;
const redirect = source.redirect;
if (typeof url !== 'string' || !url.trim()) {
throw new AuthError('OAuth redirect URL missing in response', 500);
}
return {
url,
redirect: redirect !== false,
};
}
export async function getAuthSession(): Promise<AuthSessionPayload | null> {
const response = await fetch(`${AUTH_BASE}/get-session`, {
method: 'GET',
credentials: 'include',
});
if (response.status === 401) {
return null;
}
const payload = await response.json().catch(() => null);
if (!response.ok) {
const objectPayload = payload as Record<string, unknown> | null;
const message =
(objectPayload?.message as string | undefined) ??
(objectPayload?.error as string | undefined) ??
`Auth session request failed with status ${response.status}`;
throw new AuthError(message, response.status);
}
return normalizeSessionPayload(payload);
}
export async function signInWithEmail(email: string, password: string): Promise<AuthSessionPayload | null> {
const payload = await authRequest('/sign-in/email', {
method: 'POST',
body: JSON.stringify({
email,
password,
}),
});
return normalizeSessionPayload(payload);
}
export async function signUpWithEmail(name: string, email: string, password: string): Promise<AuthSessionPayload | null> {
const payload = await authRequest('/sign-up/email', {
method: 'POST',
body: JSON.stringify({
name,
email,
password,
}),
});
return normalizeSessionPayload(payload);
}
export async function startGitHubSignIn(callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/social', {
method: 'POST',
body: JSON.stringify({
provider: 'github',
callbackURL,
disableRedirect: true,
}),
});
const oauth = parseOAuthRedirectPayload(payload);
if (oauth.redirect) {
window.location.assign(oauth.url);
}
}
export async function startGoogleSignIn(callbackURL: string): Promise<void> {
const payload = await authRequest('/sign-in/social', {
method: 'POST',
body: JSON.stringify({
provider: 'google',
callbackURL,
disableRedirect: true,
}),
});
const oauth = parseOAuthRedirectPayload(payload);
if (oauth.redirect) {
window.location.assign(oauth.url);
}
}
export async function signOutAuthSession(): Promise<void> {
await authRequest('/sign-out', {
method: 'POST',
body: JSON.stringify({}),
});
}