Add login-required user management (admin/user roles, Config->Benutzer)

No self-signup: admins create every account in Config -> Benutzer with a
typed start password; a forced password change gates first use. Session-
cookie auth protects all /api/* routes; /api/users additionally requires
the admin role. See docs/authentication.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-28 19:51:47 +02:00
parent b50c00c0ac
commit c56cfb30ad
36 changed files with 4928 additions and 38 deletions

View file

@ -0,0 +1,86 @@
// Small, self-contained fetch helper for the auth/user endpoints - kept
// separate from the large domain api.ts so this feature doesn't have to
// touch that file's shared `request()` internals.
export type UserRole = "admin" | "user";
export interface AuthStatusDto {
signedIn: boolean;
mustChangePassword: boolean;
}
export interface AuthUserDto {
id: string;
username: string;
name: string;
role: UserRole;
mustChangePassword: boolean;
}
export interface UserDto {
id: string;
username: string;
name: string;
role: UserRole;
active: boolean;
mustChangePassword: boolean;
createdAt: string;
}
export class AuthApiError extends Error {
constructor(
message: string,
public readonly status: number
) {
super(message);
}
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
cache: "no-store",
});
if (!response.ok) {
let message = `Anfrage fehlgeschlagen (Status ${response.status})`;
try {
const body = (await response.json()) as { error?: unknown };
if (typeof body.error === "string") message = body.error;
} catch {
// Keep the generic message for non-JSON error responses.
}
throw new AuthApiError(message, response.status);
}
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export const authApi = {
status: () => request<AuthStatusDto>("/api/auth/status"),
login: (username: string, password: string) =>
request<AuthStatusDto>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
}),
me: () => request<AuthUserDto>("/api/auth/me"),
changePassword: (currentPassword: string, newPassword: string) =>
request<{ changed: true }>("/api/auth/change-password", {
method: "POST",
body: JSON.stringify({ currentPassword, newPassword }),
}),
logout: () => request<{ signedOut: true }>("/api/auth/logout", { method: "POST" }),
};
export const userApi = {
list: () => request<UserDto[]>("/api/users"),
create: (input: { username: string; name: string; role: UserRole; initialPassword: string }) =>
request<UserDto>("/api/users", { method: "POST", body: JSON.stringify(input) }),
update: (userId: string, patch: { role?: UserRole; active?: boolean }) =>
request<UserDto>(`/api/users/${userId}`, { method: "PATCH", body: JSON.stringify(patch) }),
resetPassword: (userId: string, newPassword: string) =>
request<UserDto>(`/api/users/${userId}/reset-password`, {
method: "POST",
body: JSON.stringify({ newPassword }),
}),
};