remove the dead multi-user surface
Officer is single-user: the server owner is the only account, created once by /auth/bootstrap. Everything that existed to serve additional users was unreachable, so it is gone rather than left looking like it does something. Accounts: drop the invite / resend-invite / delete / list-users routes and the Users settings screen, the inert /auth/signup handler, and the account verification chain it fed (verify, resend-verification, VerifyScreen, the UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token survives for password resets only, and now requires a reset-password token rather than accepting any signed JWT. Roles: drop the users.role column and the four-value USER_ROLES enum. The permissions table granted every role identical methods, and every role === 'Super Admin' check was permanently true. The JWT no longer carries a role claim. Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected only for non-Super-Admin users, so it never ran. It was also not a usable agent jail as written — --share-net, the project root (with .env) bound read-only, and runuser dropping to the server's own uid. Rebuilding it for agent containment would be a different construction, and git history keeps this one. getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the owner's real login home, which is what terminals, chats and task runs use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
92de996412
commit
044aacf4d5
@@ -83,10 +83,3 @@ async function seedShellConfigs(homeDir: string): Promise<void> {
|
||||
// Ensure .local/bin exists
|
||||
mkdirSync(join(homeDir, '.local', 'bin'), { recursive: true });
|
||||
}
|
||||
|
||||
export function deprovisionUserEnvironment(email: string, _username: string): boolean {
|
||||
// User data directories are intentionally kept on disk.
|
||||
// This function exists for API compatibility.
|
||||
console.log(`[provision] deprovision called for ${email} (no-op, data kept on disk)`);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,111 +1,10 @@
|
||||
import { getUsers, getUserByEmail, getUserById, createUser, deleteUser } from 'officerdb';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { sign } from '@@/jwt';
|
||||
import { USER_ROLES } from 'definitions';
|
||||
import { sendMail } from 'emailer';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { originMiddleware } from '@@/_middlewares';
|
||||
import { updateUserHandler } from './update-user';
|
||||
import { deprovisionUserEnvironment } from './provision';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
|
||||
// List all users (Super Admin only)
|
||||
usersRouter.get('/', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
if (user.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const users = await getUsers();
|
||||
const sanitized = users.map(({ password, ...rest }) => rest);
|
||||
|
||||
return ctx.json(sanitized);
|
||||
});
|
||||
|
||||
// Self-update (any authenticated user)
|
||||
// Self-update. Officer is single-user: the server owner is the only account, so there is no user
|
||||
// listing, invitation or deletion — the account is created once by /auth/bootstrap.
|
||||
usersRouter.put('/', updateUserHandler);
|
||||
|
||||
// Invite a new user (Super Admin only)
|
||||
usersRouter.post('/invite', async (ctx) => {
|
||||
const reqUser = ctx.get('user');
|
||||
if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const { email, role } = ctx.get('body');
|
||||
if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw errors.BAD_REQUEST('Invalid email address');
|
||||
}
|
||||
|
||||
const validRoles = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||
const assignedRole =
|
||||
typeof role === 'string' && validRoles.includes(role as (typeof validRoles)[number])
|
||||
? (role as (typeof USER_ROLES)[number])
|
||||
: ('Member' as const);
|
||||
|
||||
const existing = await getUserByEmail(email);
|
||||
if (existing) throw errors.CONFLICT('A user with this email already exists');
|
||||
|
||||
const dbUser = await createUser({
|
||||
email,
|
||||
role: assignedRole,
|
||||
status: 'Invited',
|
||||
});
|
||||
|
||||
const origin = ctx.get('origin');
|
||||
const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h');
|
||||
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'UserInvite',
|
||||
subject: 'You have been invited to officer.dev',
|
||||
to: email,
|
||||
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
||||
});
|
||||
|
||||
const { password, ...safeUser } = dbUser;
|
||||
return ctx.json(safeUser);
|
||||
});
|
||||
|
||||
// Resend invite (Super Admin only, status must be Invited)
|
||||
usersRouter.post('/:id/resend-invite', async (ctx) => {
|
||||
const reqUser = ctx.get('user');
|
||||
if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
|
||||
|
||||
const target = await getUserById(id);
|
||||
if (!target) throw errors.NOT_FOUND('User not found');
|
||||
if (target.status !== 'Invited') throw errors.BAD_REQUEST('User is not in Invited status');
|
||||
|
||||
const origin = ctx.get('origin');
|
||||
const verificationCode = await sign({ id: target.id, email: target.email }, '24h');
|
||||
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
|
||||
|
||||
await sendMail({
|
||||
template: 'UserInvite',
|
||||
subject: 'You have been invited to officer.dev',
|
||||
to: target.email,
|
||||
data: { invitedBy: reqUser.name ?? reqUser.email, url },
|
||||
});
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
// Delete a user (Super Admin only, cannot delete self)
|
||||
usersRouter.delete('/:id', async (ctx) => {
|
||||
const reqUser = ctx.get('user');
|
||||
if (reqUser.role !== 'Super Admin') throw errors.FORBIDDEN();
|
||||
|
||||
const id = Number(ctx.req.param('id'));
|
||||
if (!id || isNaN(id)) throw errors.BAD_REQUEST('Invalid user ID');
|
||||
if (id === reqUser.id) throw errors.BAD_REQUEST('Cannot delete yourself');
|
||||
|
||||
const target = await getUserById(id);
|
||||
if (!target) throw errors.NOT_FOUND('User not found');
|
||||
|
||||
// Deprovision user environment before deleting from database
|
||||
deprovisionUserEnvironment(target.email, target.username ?? '');
|
||||
|
||||
await deleteUser(id);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user