fixed members login and permissions issues
This commit is contained in:
@@ -49,20 +49,22 @@ export function rateLimiter(options: RateLimiterOptions): MiddlewareHandler {
|
||||
const now = Date.now();
|
||||
const entry = store.get(key);
|
||||
|
||||
if (entry && entry.resetAt > now) {
|
||||
if (entry.count >= max) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
throw errors.TOO_MANY_REQUESTS(message, retryAfter);
|
||||
}
|
||||
entry.count++;
|
||||
} else {
|
||||
store.set(key, {
|
||||
count: 1,
|
||||
resetAt: now + windowMs,
|
||||
});
|
||||
if (entry && entry.resetAt > now && entry.count >= max) {
|
||||
const retryAfter = Math.ceil((entry.resetAt - now) / 1000);
|
||||
throw errors.TOO_MANY_REQUESTS(message, retryAfter);
|
||||
}
|
||||
|
||||
await next();
|
||||
|
||||
// Only count failed attempts (4xx status codes)
|
||||
const status = ctx.res.status;
|
||||
if (status >= 400 && status < 500) {
|
||||
if (entry && entry.resetAt > now) {
|
||||
entry.count++;
|
||||
} else {
|
||||
store.set(key, { count: 1, resetAt: now + windowMs });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { officerdb, eq, Users, TokenBlacklist } from 'officerdb';
|
||||
// Role permissions: which HTTP methods each role can use
|
||||
// Roles not listed here are denied by default (fail-safe)
|
||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
Member: ['GET'],
|
||||
Member: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
Admin: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
Owner: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
'Super Admin': ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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' });
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
+2
-2
@@ -5,7 +5,7 @@ import type { HonoVariables } from './create-router';
|
||||
import { authRouter } from './api/auth';
|
||||
import { serverSettingsRouter } from './api/server-settings/server-settings';
|
||||
import { landingPageDataRouter } from './api/landing-page-data/landing-page-data';
|
||||
import { updateUserHandler } from './api/users/update-user';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { plansRouter } from './api/plans/plans';
|
||||
import { skillsRouter } from './api/skills/skills';
|
||||
import { tasksRouter } from './api/tasks/tasks';
|
||||
@@ -46,7 +46,7 @@ const protectedRouter = createRouter();
|
||||
protectedRouter.use(bodyParser());
|
||||
protectedRouter.use(userMiddleware);
|
||||
|
||||
protectedRouter.put('/users', updateUserHandler);
|
||||
protectedRouter.route('/users', usersRouter);
|
||||
protectedRouter.route('/plans', plansRouter);
|
||||
protectedRouter.route('/skills', skillsRouter);
|
||||
protectedRouter.route('/tasks', tasksRouter);
|
||||
|
||||
Reference in New Issue
Block a user