fixed members login and permissions issues

This commit is contained in:
2026-02-23 00:57:34 +00:00
parent ed0debfeac
commit 97da2e2736
42 changed files with 574 additions and 113 deletions
+3 -2
View File
@@ -15,7 +15,7 @@ export const changePasswordHandler: Handler = async function (ctx) {
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.id, reqUser.id),
columns: { password: true },
columns: { password: true, username: true },
});
if (!dbUser) throw errors.UNAUTHORIZED();
@@ -31,7 +31,8 @@ export const changePasswordHandler: Handler = async function (ctx) {
await officerdb.update(Users).set({ password: newPasswordHash, passwordChangedAt }).where(eq(Users.id, reqUser.id));
const { id, email, name, role } = reqUser;
const token = await sign({ id, email, name, role });
const username = dbUser.username ?? reqUser.username;
const token = await sign({ id, email, name, username, role });
return ctx.json({ token });
};
+2 -3
View File
@@ -3,10 +3,9 @@ import { officerdb, eq, Users } from 'officerdb';
import { sign } from '@@/jwt';
import { sendMail } from 'emailer';
const { PUBLIC_URL } = process.env;
export const forgotPasswordHandler: Handler = async function (ctx) {
const { email } = ctx.get('body');
const origin = ctx.get('origin');
const dbUser = await officerdb.query.Users.findFirst({
where: eq(Users.email, email),
@@ -14,7 +13,7 @@ export const forgotPasswordHandler: Handler = async function (ctx) {
if (!dbUser) return ctx.json({ ok: true });
const verificationCode = await sign({ id: dbUser.id, email, purpose: 'reset-password' }, '6h');
const url = `${PUBLIC_URL}/auth/reset-password?verificationCode=${verificationCode}`;
const url = `${origin}/auth/reset-password?verificationCode=${verificationCode}`;
await sendMail({
template: 'ForgotPassword',
+3 -1
View File
@@ -200,12 +200,13 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
if (!dbUser) throw errors.UNAUTHORIZED();
const { id, name, role } = dbUser;
const { id, name, username, role } = dbUser;
const passkeys = dbUser.passkeys?.length ?? 0;
const token = await sign({
id,
email,
name,
username,
role,
passkeys,
});
@@ -216,6 +217,7 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
id,
email,
name,
username,
role,
passkeys,
},
+2 -2
View File
@@ -27,12 +27,12 @@ export const signinHandler: Handler = async function (ctx) {
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
if (!isValidPassword) throw errors.UNAUTHORIZED();
const { id, name, role } = dbUser;
const { id, name, username, role } = dbUser;
mkdir(join(getClaudeDir(email), 'archived'), { recursive: true }).catch(() => {});
syncUserPiConfig(email).catch(() => {});
const tokenUser = { id, email, name, role, passkeys: passkeys.length };
const tokenUser = { id, email, name, username, role, passkeys: passkeys.length };
if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) {
return ctx.json({ user: tokenUser });
+3 -3
View File
@@ -17,7 +17,7 @@ export const verifyTokenHandler: Handler = async function (ctx) {
// Bootstrap token: has email but no id (user not yet created)
if (userInfo?.email && !userInfo?.id) {
return ctx.json({ ok: true, email: userInfo.email });
return ctx.json({ ok: true, email: userInfo.email, flow: 'bootstrap' });
}
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
@@ -29,7 +29,7 @@ export const verifyTokenHandler: Handler = async function (ctx) {
// Reset-password tokens skip the verification status check
const isResetToken = (userInfo as Record<string, unknown>).purpose === 'reset-password';
if (!isResetToken && user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
if (!isResetToken && user.status !== 'Unverified' && user.status !== 'Invited') throw errors.BAD_REQUEST('Account is already verified');
return ctx.json({ ok: true, email: user.email });
return ctx.json({ ok: true, email: user.email, flow: user.status === 'Invited' ? 'invite' : 'verify' });
};
+23 -3
View File
@@ -7,7 +7,7 @@ import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
export const verifyHandler: Handler = async function (ctx) {
const { verificationCode, name, password, confirmPassword } = ctx.get('body');
const { verificationCode, name, username, password, confirmPassword } = ctx.get('body');
const userInfo = (await verifyJwt(verificationCode)) as User;
if (!userInfo) throw errors.BAD_REQUEST();
@@ -25,6 +25,10 @@ export const verifyHandler: Handler = async function (ctx) {
updates.name = name.trim();
}
if (username && typeof username === 'string' && username.trim()) {
updates.username = username.trim();
}
if (password) {
validatePassword(password);
if (password !== confirmPassword) {
@@ -33,10 +37,26 @@ export const verifyHandler: Handler = async function (ctx) {
updates.password = await argon2.hash(password);
}
await officerdb.update(Users).set(updates).where(eq(Users.id, userInfo.id));
const [updatedUser] = await officerdb
.update(Users)
.set(updates)
.where(eq(Users.id, userInfo.id))
.returning({ username: Users.username });
// Re-fetch user to get final values after update
const finalUser = await officerdb.query.Users.findFirst({
where: eq(Users.id, userInfo.id),
});
if (!finalUser) throw errors.NOT_FOUND('User not found');
// Issue a token so the user is logged in immediately
const token = await sign({ id: userInfo.id, email: userInfo.email });
const token = await sign({
id: finalUser.id,
email: finalUser.email,
name: finalUser.name,
username: finalUser.username,
role: finalUser.role,
});
return ctx.json({ ok: true, token });
};
+10 -6
View File
@@ -1,3 +1,4 @@
import { join, relative } from "path";
import type { Subprocess } from "bun";
import type { PiEvent, MessageCost } from "./types";
import { readApiKeys } from "../server-settings/pi-mono";
@@ -8,11 +9,10 @@ export type PiEventHandler = (event: PiEvent) => void;
type SandboxOptions = {
userId: number;
username: string;
homeDir: string;
};
const CONTAINER_HOME = '/home/officer';
const CONTAINER_PI_CONFIG = '/home/officer/.pi/agent';
export async function spawnPi(
cwd: string,
model: string,
@@ -25,21 +25,25 @@ export async function spawnPi(
const storedKeys = await readApiKeys();
const dockerPath = Bun.which('docker') ?? 'docker';
const containerId = `officer-terminal-${sandbox.userId}`;
const containerHome = `/home/${sandbox.username}`;
const containerPiConfig = `${containerHome}/.pi/agent`;
const piArgs = ['pi', '--mode', 'rpc', '--no-extensions', '--no-skills', '--no-prompt-templates', '--no-themes'];
if (model) piArgs.push('--model', model);
// Build env flags: Pi config dir + all stored API keys
const envFlags = [
'-e', `PI_CODING_AGENT_DIR=${CONTAINER_PI_CONFIG}`,
'-e', `HOME=${CONTAINER_HOME}`,
'-e', `PI_CODING_AGENT_DIR=${containerPiConfig}`,
'-e', `HOME=${containerHome}`,
];
for (const [key, value] of Object.entries(storedKeys)) {
if (value?.trim()) envFlags.push('-e', `${key}=${value.trim()}`);
}
const rel = relative(sandbox.homeDir, cwd);
const workdir = rel && !rel.startsWith('..') ? join(containerHome, rel) : containerHome;
proc = Bun.spawn([
dockerPath, 'exec', '-i',
'-w', CONTAINER_HOME,
'-w', workdir,
...envFlags,
containerId,
...piArgs,
+25 -5
View File
@@ -4,6 +4,8 @@ import type { ClientMessage, ServerMessage, Message, PiEvent } from './types';
import { sessionManager } from './session-manager';
import * as storage from './storage';
import * as piBridge from './pi-bridge';
import { join, resolve } from 'path';
import { homedir } from 'os';
import { getHomeDir, getUserSettingsFile } from '../../../servers/data-path';
import { logger } from './logger';
@@ -27,12 +29,27 @@ async function getUserDefaultModel(email: string): Promise<string | null> {
type WSData = {
userId: number;
email: string;
username: string;
role: string;
provider: string;
};
const IDLE_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour
const resolveRoot = (email: string, root?: string) => {
if (!root || root === 'home') return getHomeDir(email);
if (root === '~') return homedir();
if (root === 'officer.dev') return resolve(process.cwd(), '..');
return getHomeDir(email);
};
const resolveCwd = (home: string, cwd?: string) => {
if (!cwd || cwd === '~') return home;
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
if (cwd.startsWith('/')) return join(home, cwd.slice(1));
return home;
};
const wsToSessionMap = new WeakMap<any, string>();
function sendToClient(ws: ServerWebSocket<WSData> | null, msg: ServerMessage): void {
@@ -220,9 +237,9 @@ function createEventHandler(sessionId: string, model: string, cwd: string) {
async function handleChat(
ws: ServerWebSocket<WSData>,
msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[] }
msg: { prompt: string; sessionId?: string; model?: string; cwd?: string; cwdRoot?: string; sandboxed?: boolean; groupSlug?: string; attachmentIds?: string[] }
): Promise<void> {
const { email, userId } = ws.data;
const { email, username, userId } = ws.data;
const sessionId = msg.sessionId || randomUUID();
// Use provided model, or fall back to user default, or use system default
@@ -248,7 +265,9 @@ async function handleChat(
userDefault,
});
const cwd = msg.cwd || getHomeDir(email);
const homeDir = getHomeDir(email);
const rootDir = resolveRoot(email, msg.cwdRoot);
const cwd = resolveCwd(rootDir, msg.cwd);
const groupSlug = msg.groupSlug || null;
const sandboxed = msg.sandboxed ?? false;
@@ -262,7 +281,7 @@ async function handleChat(
if (!session.piProcess) {
try {
const onEvent = createEventHandler(sessionId, model, cwd);
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId } : undefined);
session.piProcess = await piBridge.spawnPi(cwd, model, onEvent, sandboxed ? { userId, username, homeDir } : undefined);
logger.info('Spawned Pi process for session', { sessionId, model, cwd, sandboxed });
} catch (err) {
logger.error('Failed to spawn Pi process', { sessionId, model, error: String(err) });
@@ -328,7 +347,8 @@ async function handleResume(
// Spawn fresh Pi process if needed
if (!session.piProcess) {
try {
const sandbox = session.sandboxed && session.userId ? { userId: session.userId } : undefined;
const homeDir = getHomeDir(email);
const sandbox = session.sandboxed && session.userId ? { userId: session.userId, username: ws.data.username, homeDir } : undefined;
const onEvent = createEventHandler(sessionId, session.model, session.cwd);
session.piProcess = await piBridge.spawnPi(session.cwd, session.model, onEvent, sandbox);
logger.info('Spawned fresh Pi process for resumed session', { sessionId, model: session.model, sandboxed: session.sandboxed });
@@ -52,9 +52,7 @@ RUN curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${LA
RUN npm install -g @mariozechner/pi-coding-agent
RUN mkdir -p /home/officer/Documents /home/officer/Downloads /home/officer/Music /home/officer/Videos /home/officer/Pictures /home/officer/Desktop /home/officer/Projects
WORKDIR /home/officer
WORKDIR /tmp
ENV TERMINAL_PTY_PORT=5337
+6 -5
View File
@@ -17,17 +17,18 @@ fi
# Create group and user
groupadd -g "$USER_GID" "$USERNAME" 2>/dev/null || true
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/officer "$USERNAME" 2>/dev/null || true
mkdir -p /home/$USERNAME
useradd -u "$USER_UID" -g "$USER_GID" -s /bin/zsh -d /home/$USERNAME "$USERNAME" 2>/dev/null || true
# Passwordless sudo
echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/terminal-user
chmod 0440 /etc/sudoers.d/terminal-user
# Seed LazyVim config if not present
if [ ! -d /home/officer/.config/nvim ]; then
mkdir -p /home/officer/.config
cp -r /opt/lazyvim-starter /home/officer/.config/nvim
chown -R "$USER_UID:$USER_GID" /home/officer/.config
if [ ! -d /home/$USERNAME/.config/nvim ]; then
mkdir -p /home/$USERNAME/.config
cp -r /opt/lazyvim-starter /home/$USERNAME/.config/nvim
chown -R "$USER_UID:$USER_GID" /home/$USERNAME/.config
fi
# Run sidecar as the user
+12 -12
View File
@@ -6,7 +6,7 @@ import { getHomeDir } from '@@/data-path';
import { syncUserPiConfig } from '@@/api/server-settings/sync-user-pi-config';
import { officerdb, Users } from 'officerdb';
type WSData = { userId: number; email: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
type WSData = { userId: number; email: string; username: string; role: string; sandboxed: boolean; sessionId?: string; cwd?: string; cols?: number; rows?: number };
type ShellInfo = { command: string; args: string[]; name: string };
type BridgeSession = {
client: ServerWebSocket<WSData>;
@@ -101,7 +101,7 @@ const ensureDockerImage = () => {
dockerImageReady = true;
};
const startDockerSidecar = (port: number, homeDir: string, userId: number, email: string): { dockerId: string } => {
const startDockerSidecar = (port: number, homeDir: string, userId: number, username: string): { dockerId: string } => {
ensureDockerImage();
const dockerPath = Bun.which('docker') ?? 'docker';
const dockerId = `officer-terminal-${userId}`;
@@ -112,7 +112,6 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, email
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
}
const username = (email.split('@')[0] ?? 'officer').replace(/[^a-z0-9_-]/g, '_').slice(0, 32);
let uid = 1000;
let gid = 1000;
try {
@@ -123,6 +122,7 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, email
// fallback to defaults
}
const containerHome = `/home/${username}`;
const run = Bun.spawnSync({
cmd: [
dockerPath,
@@ -145,9 +145,9 @@ const startDockerSidecar = (port: number, homeDir: string, userId: number, email
'-e',
`TERMINAL_GID=${gid}`,
'-v',
`${homeDir}:/home/officer`,
`${homeDir}:${containerHome}`,
'-w',
'/home/officer',
containerHome,
tag,
],
stdout: 'inherit',
@@ -210,7 +210,7 @@ const dockerStart = (dockerId: string) => {
return result.exitCode === 0;
};
const ensureDockerContainer = async (email: string, userId: number, homeDir: string) => {
const ensureDockerContainer = async (email: string, userId: number, homeDir: string, username: string) => {
const map = await loadContainerMap();
const existing = map[email];
if (existing && dockerContainerRunning(existing.dockerId)) return existing;
@@ -221,7 +221,7 @@ const ensureDockerContainer = async (email: string, userId: number, homeDir: str
}
const port = existing?.port ?? getAvailablePort(map, userId);
const docker = startDockerSidecar(port, homeDir, userId, email);
const docker = startDockerSidecar(port, homeDir, userId, username);
const next = { userId, email, dockerId: docker.dockerId, port };
map[email] = next;
await saveContainerMap(map);
@@ -269,7 +269,7 @@ const startHostSidecar = async () => {
export const initTerminalSidecars = async () => {
await startHostSidecar();
ensureDockerImage();
const users = await officerdb.select({ id: Users.id, email: Users.email }).from(Users);
const users = await officerdb.select({ id: Users.id, email: Users.email, username: Users.username }).from(Users);
for (const user of users) {
const homeDir = getHomeDir(user.email);
mkdirSync(dirname(homeDir), { recursive: true });
@@ -278,7 +278,7 @@ export const initTerminalSidecars = async () => {
await syncUserPiConfig(user.email).catch((err) => {
console.error(`[terminal] failed to sync Pi config for ${user.email}:`, err);
});
await ensureDockerContainer(user.email, user.id, homeDir);
await ensureDockerContainer(user.email, user.id, homeDir, user.username ?? user.email.split('@')[0]!);
console.log(`[terminal] sidecar ready for ${user.email}`);
} catch (err) {
console.error(`[terminal] failed to start sidecar for ${user.email}:`, err);
@@ -287,7 +287,6 @@ export const initTerminalSidecars = async () => {
};
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
const containerHome = '/home/officer';
const resolveCwd = (home: string, cwd?: string) => {
if (!cwd || cwd === '~') return home;
@@ -298,7 +297,7 @@ const resolveCwd = (home: string, cwd?: string) => {
export const terminalWebsocket = {
async open(ws: ServerWebSocket<WSData>) {
const { email, role, sandboxed } = ws.data;
const { email, username, role, sandboxed } = ws.data;
if (!sandboxed && role !== 'Super Admin') {
sendOutput(ws, '\r\n[Permission denied] Host terminal requires Super Admin role.\r\n');
@@ -363,7 +362,7 @@ export const terminalWebsocket = {
let sidecar: WebSocket | null = null;
let info: ContainerInfo | undefined;
try {
info = await ensureDockerContainer(email, ws.data.userId, cwd);
info = await ensureDockerContainer(email, ws.data.userId, cwd, username);
sidecar = await connectSidecar(info.port);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
@@ -396,6 +395,7 @@ export const terminalWebsocket = {
}
});
const containerHome = `/home/${username}`;
sidecar.send(
JSON.stringify({
type: 'init',
+2 -2
View File
@@ -3,14 +3,14 @@ import { officerdb, eq, Users } from 'officerdb';
import * as errors from '@@/custom-errors';
export const updateUserHandler: Handler = async function (ctx) {
const { name, avatar } = ctx.get('body');
const { name, username, avatar } = ctx.get('body');
const reqUser = ctx.get('user');
if (typeof name !== 'string') throw errors.BAD_REQUEST('Name is required');
await officerdb
.update(Users)
.set({ name, avatar: avatar ?? null })
.set({ name, username: typeof username === 'string' ? username : undefined, avatar: avatar ?? null })
.where(eq(Users.id, reqUser.id));
return ctx.json({ ok: true });
+109
View File
@@ -0,0 +1,109 @@
import { officerdb, eq, Users } 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';
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 officerdb.query.Users.findMany();
const sanitized = users.map(({ password, ...rest }) => rest);
return ctx.json(sanitized);
});
// Self-update (any authenticated user)
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 officerdb.query.Users.findFirst({ where: eq(Users.email, email) });
if (existing) throw errors.CONFLICT('A user with this email already exists');
const insertedUsers = await officerdb.insert(Users).values({
email,
role: assignedRole,
status: 'Invited',
}).returning();
if (!insertedUsers || insertedUsers.length === 0) throw errors.INTERNAL_SERVER_ERROR('Failed to create user');
const dbUser = insertedUsers[0]!;
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',
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 officerdb.query.Users.findFirst({ where: eq(Users.id, 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',
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 officerdb.query.Users.findFirst({ where: eq(Users.id, id) });
if (!target) throw errors.NOT_FOUND('User not found');
await officerdb.delete(Users).where(eq(Users.id, id));
return ctx.json({ ok: true });
});