mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat(multiuser): phase 5a, admin user-management API
- routes/admin-routes.ts: GET/POST /api/admin/users, PATCH/DELETE /api/admin/users/:username, reset-password, logout. Multi-user only (404 otherwise), requireAdmin, last-admin invariants, one-time-password on create / reset (returned once + mustChangePassword), disable/reset/delete revoke cookie sessions, delete kills the user's live sessions first (normal teardown) and can delete their space (guarded). Per-user stats (live/active sessions, case count). - web/admin-audit.ts: append-only ~/.codeman/admin-audit.jsonl (timestamp, acting admin, action, target, IP) for every user-management action. - SSE admin:usersChanged + auth:passwordChangeRequired (sse-events.ts + constants.js). fix(user-store): serialize users.json read-modify-write touchLastLogin fires on every Basic auth (fire-and-forget) and was racing route writes (create/update), clobbering records — a real corruption bug surfaced by the admin tests. All mutators now run under a single write lock, and touchLastLogin is throttled to once/minute per user to bound disk churn. Tests: test/admin-routes.test.ts (8, live server) + user-store lock verified by the existing user-store suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+93
-62
@@ -198,6 +198,22 @@ async function writeUsers(users: UserRecord[]): Promise<void> {
|
||||
cache = { users, ts: Date.now() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize every read-modify-write on users.json. Without this a fire-and-forget
|
||||
* touchLastLogin (fired on each Basic auth) can interleave with a route's
|
||||
* create/update and clobber records, since both do readUsers(true) → mutate →
|
||||
* writeUsers against a single shared file + tmp path.
|
||||
*/
|
||||
let mutateChain: Promise<unknown> = Promise.resolve();
|
||||
function withUsersLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const run = mutateChain.then(fn, fn);
|
||||
mutateChain = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function hasUsers(): Promise<boolean> {
|
||||
return (await readUsers()).length > 0;
|
||||
}
|
||||
@@ -259,25 +275,27 @@ export async function createUser(opts: CreateUserOptions): Promise<UserRecord> {
|
||||
if (!opts.password || opts.password.length < 8) {
|
||||
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
||||
}
|
||||
const users = await readUsers(true);
|
||||
if (users.some((u) => u.username === username)) {
|
||||
throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS');
|
||||
}
|
||||
if (users.length >= maxUsers()) {
|
||||
throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT');
|
||||
}
|
||||
const record: UserRecord = {
|
||||
username,
|
||||
role: opts.role,
|
||||
password: await hashPassword(opts.password),
|
||||
disabled: false,
|
||||
mustChangePassword: !!opts.mustChangePassword,
|
||||
canBypassPermissions: !!opts.canBypassPermissions,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
users.push(record);
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
if (users.some((u) => u.username === username)) {
|
||||
throw new UserStoreError(`User "${username}" already exists`, 'USER_EXISTS');
|
||||
}
|
||||
if (users.length >= maxUsers()) {
|
||||
throw new UserStoreError(`Maximum number of users (${maxUsers()}) reached`, 'INVALID_INPUT');
|
||||
}
|
||||
const record: UserRecord = {
|
||||
username,
|
||||
role: opts.role,
|
||||
password: await hashPassword(opts.password),
|
||||
disabled: false,
|
||||
mustChangePassword: !!opts.mustChangePassword,
|
||||
canBypassPermissions: !!opts.canBypassPermissions,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
users.push(record);
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/** Set a user's password. `mustChangePassword` is left unchanged unless specified. */
|
||||
@@ -290,13 +308,15 @@ export async function setPassword(
|
||||
throw new UserStoreError('Password must be at least 8 characters', 'INVALID_INPUT');
|
||||
}
|
||||
const norm = normalizeUsername(username);
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
record.password = await hashPassword(password);
|
||||
if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
record.password = await hashPassword(password);
|
||||
if (opts.mustChangePassword !== undefined) record.mustChangePassword = opts.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
export interface UpdateUserPatch {
|
||||
@@ -308,39 +328,48 @@ export interface UpdateUserPatch {
|
||||
|
||||
export async function updateUser(username: string, patch: UpdateUserPatch): Promise<UserRecord> {
|
||||
const norm = normalizeUsername(username);
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
return withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
|
||||
// Guard the last-enabled-admin invariant against demote/disable.
|
||||
const before = countEnabledAdmins(users);
|
||||
const projected: UserRecord = {
|
||||
...record,
|
||||
role: patch.role ?? record.role,
|
||||
disabled: patch.disabled ?? record.disabled,
|
||||
};
|
||||
const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u)));
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
// Guard the last-enabled-admin invariant against demote/disable.
|
||||
const before = countEnabledAdmins(users);
|
||||
const projected: UserRecord = {
|
||||
...record,
|
||||
role: patch.role ?? record.role,
|
||||
disabled: patch.disabled ?? record.disabled,
|
||||
};
|
||||
const after = countEnabledAdmins(users.map((u) => (u.username === norm ? projected : u)));
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot demote or disable the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
|
||||
if (patch.role !== undefined) record.role = patch.role;
|
||||
if (patch.disabled !== undefined) record.disabled = patch.disabled;
|
||||
if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions;
|
||||
if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
if (patch.role !== undefined) record.role = patch.role;
|
||||
if (patch.disabled !== undefined) record.disabled = patch.disabled;
|
||||
if (patch.canBypassPermissions !== undefined) record.canBypassPermissions = patch.canBypassPermissions;
|
||||
if (patch.mustChangePassword !== undefined) record.mustChangePassword = patch.mustChangePassword;
|
||||
await writeUsers(users);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a successful login timestamp. Best-effort; failures are swallowed. */
|
||||
/**
|
||||
* Record a successful login timestamp. Best-effort + throttled: skips the write if
|
||||
* the last login was within the last minute (Basic clients re-send credentials on
|
||||
* every request, so this fires often — the throttle keeps disk churn bounded).
|
||||
*/
|
||||
export async function touchLastLogin(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
try {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) return;
|
||||
record.lastLoginAt = Date.now();
|
||||
await writeUsers(users);
|
||||
await withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) return;
|
||||
if (record.lastLoginAt && Date.now() - record.lastLoginAt < 60_000) return;
|
||||
record.lastLoginAt = Date.now();
|
||||
await writeUsers(users);
|
||||
});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
@@ -348,16 +377,18 @@ export async function touchLastLogin(username: string): Promise<void> {
|
||||
|
||||
export async function deleteUser(username: string): Promise<void> {
|
||||
const norm = normalizeUsername(username);
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
const before = countEnabledAdmins(users);
|
||||
const remaining = users.filter((u) => u.username !== norm);
|
||||
const after = countEnabledAdmins(remaining);
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
await writeUsers(remaining);
|
||||
await withUsersLock(async () => {
|
||||
const users = await readUsers(true);
|
||||
const record = users.find((u) => u.username === norm);
|
||||
if (!record) throw new UserStoreError(`User "${norm}" not found`, 'USER_NOT_FOUND');
|
||||
const before = countEnabledAdmins(users);
|
||||
const remaining = users.filter((u) => u.username !== norm);
|
||||
const after = countEnabledAdmins(remaining);
|
||||
if (before > 0 && after === 0) {
|
||||
throw new UserStoreError('Cannot delete the last enabled admin', 'LAST_ADMIN');
|
||||
}
|
||||
await writeUsers(remaining);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @fileoverview Append-only admin audit log (~/.codeman/admin-audit.jsonl).
|
||||
*
|
||||
* Every user-management action (create/patch/reset/delete/logout/assign) writes one
|
||||
* JSON line: timestamp, acting admin, action, target, request IP. Same idiom as
|
||||
* session-lifecycle.jsonl. Best-effort: a write failure never blocks the action.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
|
||||
export interface AdminAuditEntry {
|
||||
ts: number;
|
||||
admin: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
ip?: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function appendAdminAudit(entry: Omit<AdminAuditEntry, 'ts'>): Promise<void> {
|
||||
try {
|
||||
const line = JSON.stringify({ ts: Date.now(), ...entry }) + '\n';
|
||||
await fs.appendFile(dataPath('admin-audit.jsonl'), line, { mode: 0o600 });
|
||||
} catch {
|
||||
/* best-effort audit; never block the action */
|
||||
}
|
||||
}
|
||||
@@ -481,6 +481,9 @@ const SSE_EVENTS = {
|
||||
DOCKER_IMAGE_BUILD_PROGRESS: 'docker:imageBuildProgress',
|
||||
DOCKER_IMAGE_BUILD_COMPLETE: 'docker:imageBuildComplete',
|
||||
DOCKER_IMAGE_BUILD_FAILED: 'docker:imageBuildFailed',
|
||||
// Multi-user (admin-only / targeted)
|
||||
ADMIN_USERS_CHANGED: 'admin:usersChanged',
|
||||
AUTH_PASSWORD_CHANGE_REQUIRED: 'auth:passwordChangeRequired',
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @fileoverview Admin user-management routes (multi-user mode only).
|
||||
*
|
||||
* All handlers: 404 unless multi-user mode is active, requireAdmin, and audit-logged
|
||||
* to ~/.codeman/admin-audit.jsonl. Endpoints (docs/multi-user-plan.md section 8):
|
||||
* GET /api/admin/users
|
||||
* POST /api/admin/users
|
||||
* PATCH /api/admin/users/:username
|
||||
* POST /api/admin/users/:username/reset-password
|
||||
* POST /api/admin/users/:username/logout
|
||||
* DELETE /api/admin/users/:username
|
||||
*
|
||||
* Self-service GET /api/me + POST /api/me/password live in me-routes.ts.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isMultiUserMode, userCasesDir } from '../../config/multiuser.js';
|
||||
import {
|
||||
createUser,
|
||||
deleteUser,
|
||||
deleteUserSpace,
|
||||
findUser,
|
||||
generateOneTimePassword,
|
||||
readUsers,
|
||||
setPassword,
|
||||
toPublicUser,
|
||||
updateUser,
|
||||
UserStoreError,
|
||||
} from '../../user-store.js';
|
||||
import { getAuthUser, requireAdmin, revokeUserSessions } from '../route-helpers.js';
|
||||
import { appendAdminAudit } from '../admin-audit.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { AuthPort } from '../ports/auth-port.js';
|
||||
import type { SessionPort } from '../ports/session-port.js';
|
||||
import type { EventPort } from '../ports/event-port.js';
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
username: z.string().min(1).max(64),
|
||||
role: z.enum(['admin', 'user']).default('user'),
|
||||
password: z.string().min(8).max(1024).optional(),
|
||||
canBypassPermissions: z.boolean().optional(),
|
||||
});
|
||||
const UpdateUserSchema = z.object({
|
||||
role: z.enum(['admin', 'user']).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
canBypassPermissions: z.boolean().optional(),
|
||||
});
|
||||
const DeleteUserSchema = z.object({ deleteSpace: z.boolean().optional() });
|
||||
|
||||
/** Map a UserStoreError's code onto the API error code + status. */
|
||||
function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof createErrorResponse> {
|
||||
if (err instanceof UserStoreError) {
|
||||
const code = ApiErrorCode[err.code as keyof typeof ApiErrorCode] ?? ApiErrorCode.INVALID_INPUT;
|
||||
reply.code(
|
||||
err.code === 'USER_EXISTS' || err.code === 'LAST_ADMIN' ? 409 : err.code === 'USER_NOT_FOUND' ? 404 : 400
|
||||
);
|
||||
return createErrorResponse(code, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error');
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void {
|
||||
// Gate: admin routes exist only in multi-user mode, and only for admins.
|
||||
const gate = (req: FastifyRequest, reply: FastifyReply): boolean => {
|
||||
if (!isMultiUserMode()) {
|
||||
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Not found'));
|
||||
return false;
|
||||
}
|
||||
return requireAdmin(req, reply);
|
||||
};
|
||||
const audit = (req: FastifyRequest, action: string, target?: string, detail?: Record<string, unknown>) =>
|
||||
void appendAdminAudit({ admin: getAuthUser(req).username, action, target, ip: req.ip, detail });
|
||||
|
||||
// Count a user's live sessions + active cookie sessions + case folders.
|
||||
const statsFor = (username: string) => {
|
||||
let liveSessions = 0;
|
||||
for (const s of ctx.sessions.values()) if (s.owner === username) liveSessions++;
|
||||
let activeSessions = 0;
|
||||
if (ctx.authSessions) for (const [, rec] of ctx.authSessions) if (rec.username === username) activeSessions++;
|
||||
let caseCount = 0;
|
||||
try {
|
||||
caseCount = readdirSync(userCasesDir(username), { withFileTypes: true }).filter((e) => e.isDirectory()).length;
|
||||
} catch {
|
||||
/* no cases dir yet */
|
||||
}
|
||||
return { liveSessions, activeSessions, caseCount };
|
||||
};
|
||||
|
||||
app.get('/api/admin/users', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const users = await readUsers(true);
|
||||
return {
|
||||
success: true,
|
||||
data: users.map((u) => ({ ...toPublicUser(u), stats: statsFor(u.username) })),
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/admin/users', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const parsed = CreateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
// No password given: generate a one-time password, returned ONCE, force change.
|
||||
const oneTime = parsed.data.password ? undefined : generateOneTimePassword();
|
||||
try {
|
||||
const user = await createUser({
|
||||
username: parsed.data.username,
|
||||
role: parsed.data.role,
|
||||
password: parsed.data.password ?? oneTime!,
|
||||
canBypassPermissions: parsed.data.canBypassPermissions,
|
||||
mustChangePassword: !parsed.data.password,
|
||||
});
|
||||
audit(req, 'user.create', user.username, { role: user.role });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { user: toPublicUser(user), oneTimePassword: oneTime } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/admin/users/:username', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const parsed = UpdateUserSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, parsed.error.issues[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
try {
|
||||
const user = await updateUser(username, parsed.data);
|
||||
// Disabling revokes the user's cookie sessions.
|
||||
if (parsed.data.disabled) revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.update', user.username, parsed.data);
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { user: toPublicUser(user) } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:username/reset-password', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
if (!(await findUser(username))) {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.USER_NOT_FOUND, 'No such user');
|
||||
}
|
||||
const oneTime = generateOneTimePassword();
|
||||
try {
|
||||
await setPassword(username, oneTime, { mustChangePassword: true });
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.reset-password', username);
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { oneTimePassword: oneTime } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/admin/users/:username/logout', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const revoked = revokeUserSessions(ctx.authSessions, username);
|
||||
audit(req, 'user.logout', username, { revoked });
|
||||
return { success: true, data: { revoked } };
|
||||
});
|
||||
|
||||
app.delete('/api/admin/users/:username', async (req, reply) => {
|
||||
if (!gate(req, reply)) return;
|
||||
const { username } = req.params as { username: string };
|
||||
const parsed = DeleteUserSchema.safeParse(req.body ?? {});
|
||||
const deleteSpace = parsed.success ? parsed.data.deleteSpace : false;
|
||||
try {
|
||||
// Kill the user's live sessions first (normal kill flow, incl. docker/remote
|
||||
// teardown), before removing the record.
|
||||
const owned = [...ctx.sessions.values()].filter((s) => s.owner === username).map((s) => s.id);
|
||||
for (const id of owned) {
|
||||
await ctx.cleanupSession(id, true, 'admin_delete_user').catch(() => {});
|
||||
}
|
||||
revokeUserSessions(ctx.authSessions, username);
|
||||
await deleteUser(username); // throws LAST_ADMIN / USER_NOT_FOUND
|
||||
if (deleteSpace) await deleteUserSpace(username);
|
||||
audit(req, 'user.delete', username, { deleteSpace, killedSessions: owned.length });
|
||||
ctx.broadcast(SseEvent.AdminUsersChanged, {});
|
||||
return { success: true, data: { username, deletedSpace: !!deleteSpace } };
|
||||
} catch (err) {
|
||||
return storeError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -20,4 +20,5 @@ export { registerOrchestratorRoutes } from './orchestrator-routes.js';
|
||||
export { registerClipboardRoutes } from './clipboard-routes.js';
|
||||
export { registerSearchRoutes } from './search-routes.js';
|
||||
export { registerMeRoutes } from './me-routes.js';
|
||||
export { registerAdminRoutes } from './admin-routes.js';
|
||||
export { registerWsRoutes } from './ws-routes.js';
|
||||
|
||||
@@ -158,6 +158,7 @@ import {
|
||||
registerOrchestratorRoutes,
|
||||
registerCronRoutes,
|
||||
registerMeRoutes,
|
||||
registerAdminRoutes,
|
||||
registerWsRoutes,
|
||||
} from './routes/index.js';
|
||||
import { CronService } from '../cron/cron-service.js';
|
||||
@@ -906,6 +907,7 @@ export class WebServer extends EventEmitter {
|
||||
registerClipboardRoutes(this.app, ctx);
|
||||
registerSearchRoutes(this.app, ctx);
|
||||
registerMeRoutes(this.app, ctx);
|
||||
registerAdminRoutes(this.app, ctx);
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
|
||||
// Cron: build the service from the same context, recompute
|
||||
|
||||
@@ -386,6 +386,13 @@ export const DockerImageBuildComplete = 'docker:imageBuildComplete' as const;
|
||||
/** The agent base image build failed. */
|
||||
export const DockerImageBuildFailed = 'docker:imageBuildFailed' as const;
|
||||
|
||||
// ─── Multi-user (admin-only / targeted) ──────────────────────────────────────
|
||||
|
||||
/** The user roster changed (admin-only); the Users panel re-fetches. */
|
||||
export const AdminUsersChanged = 'admin:usersChanged' as const;
|
||||
/** A user must change their password (targeted); the frontend shows the modal. */
|
||||
export const AuthPasswordChangeRequired = 'auth:passwordChangeRequired' as const;
|
||||
|
||||
// ─── Namespace Re-export ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -576,4 +583,6 @@ export const SseEvent = {
|
||||
DockerImageBuildProgress,
|
||||
DockerImageBuildComplete,
|
||||
DockerImageBuildFailed,
|
||||
AdminUsersChanged,
|
||||
AuthPasswordChangeRequired,
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user