diff --git a/apps/pwa/package.json b/apps/pwa/package.json index 6db1c08..b21b888 100644 --- a/apps/pwa/package.json +++ b/apps/pwa/package.json @@ -1,6 +1,6 @@ { "name": "@logicsrc/pwa", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "description": "LogicSRC credentials — Express + libSQL/Turso app: auth + end-to-end-encrypted team credential sharing.", diff --git a/apps/pwa/src/lib/html.mjs b/apps/pwa/src/lib/html.mjs index 87dc1f1..73c81d2 100644 --- a/apps/pwa/src/lib/html.mjs +++ b/apps/pwa/src/lib/html.mjs @@ -45,7 +45,7 @@ h1{font-size:2.2rem;line-height:1.05} .btn.block{width:100%} .btn:focus-visible{outline:2px solid var(--green);outline-offset:2px} input,textarea,select{font-family:var(--mono);font-size:.85rem;color:var(--text);background:var(--surface);border:1px solid var(--line-2);border-radius:9px;padding:11px 13px;width:100%} -input:focus,textarea:focus{outline:none;border-color:var(--green)} +input:focus,textarea:focus,select:focus{outline:none;border-color:var(--green)} input::placeholder,textarea::placeholder{color:var(--faint)} label.field{display:block;margin-bottom:14px} label.field span{display:block;font-family:var(--mono);font-size:.66rem;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:6px} @@ -86,6 +86,10 @@ label.field span{display:block;font-family:var(--mono);font-size:.66rem;letter-s table{width:100%;border-collapse:collapse;font-size:.9rem} th{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);font-family:var(--mono);font-size:.66rem;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);font-weight:600} td{padding:8px 10px;border-bottom:1px solid var(--line)} +.table-scroll{overflow-x:auto} +.member-role-form,.member-actions{display:flex;align-items:center;gap:6px;margin:0} +.member-role-form select{width:auto;min-width:94px;padding:6px 9px} +.btn.compact{padding:6px 9px;font-size:.74rem} code{font-family:var(--mono);font-size:.85em;background:var(--surface-2);padding:1px 5px;border-radius:4px} footer{border-top:1px solid var(--line);padding:26px 0;margin-top:40px} .foot{display:flex;justify-content:space-between;gap:16px;flex-wrap:wrap;align-items:center;font-family:var(--mono);font-size:.74rem;color:var(--faint)} diff --git a/apps/pwa/src/lib/team-members.mjs b/apps/pwa/src/lib/team-members.mjs new file mode 100644 index 0000000..8e4413f --- /dev/null +++ b/apps/pwa/src/lib/team-members.mjs @@ -0,0 +1,172 @@ +import { get, batch } from "../db.mjs"; +import { id, token, sha256 } from "./crypto.mjs"; +import { config } from "../config.mjs"; + +export const TEAM_ROLES = ["owner", "admin", "member"]; +export const ROLE_RANK = { member: 0, admin: 1, owner: 2 }; +export const INVITE_TTL = 1000 * 60 * 60 * 24 * 7; +export const normEmail = (email) => String(email || "").trim().toLowerCase(); + +export class TeamMemberError extends Error { + constructor(status, message, code = "member-error") { + super(message); + this.name = "TeamMemberError"; + this.status = status; + this.code = code; + } +} + +export function canManageMember(actor, target) { + if (!actor || actor.status !== "active") return false; + if (actor.role === "owner") return true; + return actor.role === "admin" && target.role === "member"; +} + +function assertInviteRole(actor, role) { + if (!TEAM_ROLES.includes(role)) { + throw new TeamMemberError(422, "Role must be owner, admin, or member.", "bad-role"); + } + if (actor.role !== "owner" && !(actor.role === "admin" && role === "member")) { + throw new TeamMemberError(403, "Only owners can invite admins or owners.", "not-allowed"); + } +} + +async function sendInviteEmail(to, tok, team, fromEmail) { + if (!config.resend.apiKey) return false; + const url = `${config.origin}/teams/accept?token=${encodeURIComponent(tok)}`; + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { authorization: `Bearer ${config.resend.apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({ + from: config.resend.from, + to, + subject: `You're invited to the "${team.name}" credential team on LogicSRC`, + text: `${fromEmail} invited you to share credentials on ${team.name} (${team.slug}).\n\nAccept in the CLI:\n logicsrc login\n logicsrc teams accept ${tok}\n\nOr on the web: ${url}\n\nSecrets are end-to-end encrypted — the server never sees them.` + }) + }).catch(() => null); + return Boolean(response && response.ok); +} + +// Create or resend an invite. A resend replaces every unused invite for this +// team/email pair so the old key stops working immediately. +export async function issueTeamInvite({ team, actor, email: rawEmail, role: rawRole }) { + if (!actor || actor.status !== "active" || ROLE_RANK[actor.role] < ROLE_RANK.admin) { + throw new TeamMemberError(403, "Only owners and admins can invite.", "not-allowed"); + } + const email = normEmail(rawEmail); + if (!email) throw new TeamMemberError(422, "Expected an email address.", "bad-email"); + const role = String(rawRole || "member"); + assertInviteRole(actor, role); + + const existing = await get(`SELECT * FROM credshare_members WHERE team_id = ? AND email = ?`, [team.id, email]); + if (existing?.status === "active") { + throw new TeamMemberError(409, `${email} is already an active team member.`, "already-member"); + } + if (existing && actor.role === "admin" && existing.role !== "member") { + throw new TeamMemberError(403, "Admins cannot replace an owner or admin invite.", "not-allowed"); + } + + const invitedUser = await get(`SELECT id FROM users WHERE email = ?`, [email]); + const tok = token(24); + const now = Date.now(); + const inviteId = id(); + const statements = []; + if (existing) { + statements.push({ + sql: `UPDATE credshare_members SET user_id = COALESCE(user_id, ?), role = ?, invited_by = ? WHERE id = ?`, + args: [invitedUser?.id ?? null, role, actor.user_id, existing.id] + }); + } else { + statements.push({ + sql: `INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`, + args: [id(), team.id, invitedUser?.id ?? null, email, role, "invited", actor.user_id, now] + }); + } + statements.push( + { + sql: `DELETE FROM credshare_invites WHERE team_id = ? AND email = ? AND accepted_at IS NULL`, + args: [team.id, email] + }, + { + sql: `INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`, + args: [inviteId, team.id, email, role, sha256(tok), actor.user_id, now + INVITE_TTL, now] + }, + { + sql: `INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`, + args: [id(), team.id, null, actor.user_id, existing ? "team:invite:resend" : "team:invite", email, null, now] + } + ); + await batch(statements); + + const emailSent = await sendInviteEmail(email, tok, team, actor.email); + return { + invite: { id: inviteId, email, role, expiresAt: now + INVITE_TTL }, + emailSent, + token: tok, + resent: Boolean(existing) + }; +} + +export async function changeMemberRole({ team, actor, memberId, role }) { + if (!actor || actor.status !== "active" || actor.role !== "owner") { + throw new TeamMemberError(403, "Only owners can change member permissions.", "not-allowed"); + } + if (!TEAM_ROLES.includes(role)) { + throw new TeamMemberError(422, "Role must be owner, admin, or member.", "bad-role"); + } + const target = await get(`SELECT * FROM credshare_members WHERE id = ? AND team_id = ?`, [memberId, team.id]); + if (!target) throw new TeamMemberError(404, "Team member not found.", "member-not-found"); + if (target.role === role) return target; + + if (target.role === "owner" && target.status === "active" && role !== "owner") { + const owners = await get(`SELECT COUNT(*) AS n FROM credshare_members WHERE team_id = ? AND role = 'owner' AND status = 'active'`, [team.id]); + if (Number(owners?.n || 0) <= 1) { + throw new TeamMemberError(409, "A team must keep at least one active owner.", "last-owner"); + } + } + + const now = Date.now(); + await batch([ + { sql: `UPDATE credshare_members SET role = ? WHERE id = ?`, args: [role, target.id] }, + { sql: `UPDATE credshare_invites SET role = ? WHERE team_id = ? AND email = ? AND accepted_at IS NULL`, args: [role, team.id, target.email] }, + { + sql: `INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`, + args: [id(), team.id, null, actor.user_id, `team:member:role:${target.role}->${role}`, target.email, null, now] + } + ]); + return { ...target, role }; +} + +export async function removeTeamMember({ team, actor, memberId }) { + const target = await get(`SELECT * FROM credshare_members WHERE id = ? AND team_id = ?`, [memberId, team.id]); + if (!target) throw new TeamMemberError(404, "Team member not found.", "member-not-found"); + if (!canManageMember(actor, target)) { + throw new TeamMemberError(403, "You cannot remove this team member.", "not-allowed"); + } + + if (target.role === "owner" && target.status === "active") { + const owners = await get(`SELECT COUNT(*) AS n FROM credshare_members WHERE team_id = ? AND role = 'owner' AND status = 'active'`, [team.id]); + if (Number(owners?.n || 0) <= 1) { + throw new TeamMemberError(409, "A team must keep at least one active owner.", "last-owner"); + } + } + + const grantCount = target.user_id + ? await get(`SELECT COUNT(*) AS n FROM credshare_vault_grants WHERE user_id = ? AND vault_id IN (SELECT id FROM credshare_vaults WHERE team_id = ?)`, [target.user_id, team.id]) + : null; + const revokedVaultGrants = Number(grantCount?.n || 0); + const now = Date.now(); + await batch([ + { + sql: `DELETE FROM credshare_vault_grants WHERE user_id = ? AND vault_id IN (SELECT id FROM credshare_vaults WHERE team_id = ?)`, + args: [target.user_id || "", team.id] + }, + { sql: `DELETE FROM credshare_invites WHERE team_id = ? AND email = ? AND accepted_at IS NULL`, args: [team.id, target.email] }, + { sql: `DELETE FROM credshare_members WHERE id = ?`, args: [target.id] }, + { + sql: `INSERT INTO credshare_audit (id, team_id, vault_id, actor_user_id, action, key_name, fingerprint, created_at) VALUES (?,?,?,?,?,?,?,?)`, + args: [id(), team.id, null, actor.user_id, "team:member:remove", target.email, null, now] + } + ]); + return { member: target, revokedVaultGrants, rotationRequired: revokedVaultGrants > 0 }; +} diff --git a/apps/pwa/src/routes/credshare.mjs b/apps/pwa/src/routes/credshare.mjs index eee776c..85a48b5 100644 --- a/apps/pwa/src/routes/credshare.mjs +++ b/apps/pwa/src/routes/credshare.mjs @@ -7,15 +7,19 @@ // `Bearer lsk_…` API key (the logicsrc CLI). Mounted at /api/credshare. import { Router } from "express"; import { get, all, run, batch } from "../db.mjs"; -import { id, token, sha256 } from "../lib/crypto.mjs"; +import { id, sha256 } from "../lib/crypto.mjs"; import { bearer, userForApiKey } from "../lib/apikey.mjs"; -import { config } from "../config.mjs"; +import { + TeamMemberError, + normEmail, + issueTeamInvite, + changeMemberRole, + removeTeamMember +} from "../lib/team-members.mjs"; export const credshareRouter = Router(); -const ROLE_RANK = { member: 0, admin: 1, owner: 2 }; -const INVITE_TTL = 1000 * 60 * 60 * 24 * 7; -const norm = (e) => String(e || "").trim().toLowerCase(); +const norm = normEmail; const slugify = (s) => { const v = String(s || "").trim().toLowerCase(); return /^[a-z0-9][a-z0-9-]{0,62}$/.test(v) ? v : null; @@ -57,21 +61,6 @@ async function audit(ev) { [id(), ev.teamId ?? null, ev.vaultId ?? null, ev.actorUserId, ev.action, ev.keyName ?? null, ev.fingerprint ?? null, Date.now()]); } -async function sendInviteEmail(to, tok, team, fromEmail) { - if (!config.resend.apiKey) return false; - const url = `${config.origin}/teams/accept?token=${encodeURIComponent(tok)}`; - const r = await fetch("https://api.resend.com/emails", { - method: "POST", - headers: { authorization: `Bearer ${config.resend.apiKey}`, "content-type": "application/json" }, - body: JSON.stringify({ - from: config.resend.from, to, - subject: `You're invited to the "${team.name}" credential team on LogicSRC`, - text: `${fromEmail} invited you to share credentials on ${team.name} (${team.slug}).\n\nAccept in the CLI:\n logicsrc login\n logicsrc teams accept ${tok}\n\nOr on the web: ${url}\n\nSecrets are end-to-end encrypted — the server never sees them.` - }) - }).catch(() => null); - return Boolean(r && r.ok); -} - // ---- identity key + lookup ---- credshareRouter.post("/api/credshare/keys", api(async (req, res, user) => { const publicKey = req.body?.publicKey; @@ -115,28 +104,61 @@ credshareRouter.get("/api/credshare/teams/:slug/members", api(async (req, res, u const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return; const rows = await all(`SELECT * FROM credshare_members WHERE team_id = ? ORDER BY created_at`, [ctx.team.id]); const members = []; - for (const m of rows) members.push({ email: m.email, role: m.role, status: m.status, hasPublicKey: m.user_id ? Boolean(await publicKeyFor(m.user_id)) : false, joinedAt: m.joined_at }); + for (const m of rows) members.push({ id: m.id, email: m.email, role: m.role, status: m.status, hasPublicKey: m.user_id ? Boolean(await publicKeyFor(m.user_id)) : false, joinedAt: m.joined_at }); res.json({ members }); })); credshareRouter.post("/api/credshare/teams/:slug/invites", api(async (req, res, user) => { const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return; - if (ROLE_RANK[ctx.member.role] < ROLE_RANK.admin) return res.status(403).json({ error: "Only owners and admins can invite." }); - const email = norm(req.body?.email); - if (!email) return res.status(422).json({ error: "Expected { email, role? }." }); - const role = req.body?.role && ROLE_RANK[req.body.role] != null ? req.body.role : "member"; - const existing = await get(`SELECT 1 FROM credshare_members WHERE team_id = ? AND email = ?`, [ctx.team.id, email]); - if (!existing) { - const invitedUser = await get(`SELECT id FROM users WHERE email = ?`, [email]); - await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`, - [id(), ctx.team.id, invitedUser?.id ?? null, email, role, "invited", user.id, Date.now()]); + try { + const result = await issueTeamInvite({ + team: ctx.team, + actor: { ...ctx.member, email: user.email }, + email: req.body?.email, + role: req.body?.role + }); + res.status(201).json({ + invite: result.invite, + emailSent: result.emailSent, + resent: result.resent, + ...(result.emailSent ? {} : { token: result.token }) + }); + } catch (error) { + if (error instanceof TeamMemberError) return res.status(error.status).json({ error: error.message, code: error.code }); + throw error; + } +})); + +credshareRouter.patch("/api/credshare/teams/:slug/members/:memberId", api(async (req, res, user) => { + const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return; + try { + const member = await changeMemberRole({ + team: ctx.team, + actor: ctx.member, + memberId: req.params.memberId, + role: req.body?.role + }); + res.json({ member: { id: member.id, email: member.email, role: member.role, status: member.status } }); + } catch (error) { + if (error instanceof TeamMemberError) return res.status(error.status).json({ error: error.message, code: error.code }); + throw error; + } +})); + +credshareRouter.delete("/api/credshare/teams/:slug/members/:memberId", api(async (req, res, user) => { + const ctx = await requireMember(res, req.params.slug, user.id); if (!ctx) return; + try { + const result = await removeTeamMember({ team: ctx.team, actor: ctx.member, memberId: req.params.memberId }); + res.json({ + ok: true, + removed: result.member.email, + revokedVaultGrants: result.revokedVaultGrants, + rotationRequired: result.rotationRequired + }); + } catch (error) { + if (error instanceof TeamMemberError) return res.status(error.status).json({ error: error.message, code: error.code }); + throw error; } - const tok = token(24); - await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`, - [id(), ctx.team.id, email, role, sha256(tok), user.id, Date.now() + INVITE_TTL, Date.now()]); - await audit({ teamId: ctx.team.id, actorUserId: user.id, action: "team:invite", keyName: email }); - const emailSent = await sendInviteEmail(email, tok, ctx.team, user.email); - res.status(201).json({ invite: { email, role }, emailSent, ...(emailSent ? {} : { token: tok }) }); })); credshareRouter.post("/api/credshare/invites/accept", api(async (req, res, user) => { diff --git a/apps/pwa/src/routes/pages.mjs b/apps/pwa/src/routes/pages.mjs index 28486a5..4341cf7 100644 --- a/apps/pwa/src/routes/pages.mjs +++ b/apps/pwa/src/routes/pages.mjs @@ -3,13 +3,20 @@ // members, vaults (ciphertext metadata), invites, and CLI API keys. import { Router } from "express"; import { get, all, run } from "../db.mjs"; -import { id, token, sha256 } from "../lib/crypto.mjs"; +import { id, sha256 } from "../lib/crypto.mjs"; import { page, footer, appBar, esc } from "../lib/html.mjs"; import { requireAuth, csrfInput } from "../lib/session.mjs"; import { createApiKey, listApiKeys, revokeApiKey } from "../lib/apikey.mjs"; import { requestOrigin } from "../lib/origin.mjs"; import { CLI_HINT } from "../lib/cli-hint.mjs"; import { config } from "../config.mjs"; +import { + TeamMemberError, + canManageMember, + issueTeamInvite, + changeMemberRole, + removeTeamMember +} from "../lib/team-members.mjs"; export const pagesRouter = Router(); @@ -25,7 +32,18 @@ async function teamCard(team, uid) { const memberRows = []; for (const m of members) { const key = m.user_id ? await get(`SELECT 1 FROM credshare_keys WHERE user_id = ?`, [m.user_id]) : null; - memberRows.push(`${esc(m.email)}${esc(m.role)}${esc(m.status)}${key ? "✓" : "—"}`); + const roleControl = me.role === "owner" + ? `
${CSRF} +
` + : `${esc(m.role)}`; + const canResend = m.status === "invited" && (me.role === "owner" || (me.role === "admin" && m.role === "member")); + const actions = [ + canResend ? `
${CSRF}
` : "", + canManageMember(me, m) ? `
${CSRF}
` : "" + ].filter(Boolean).join(""); + memberRows.push(`${esc(m.email)}${roleControl}${esc(m.status)}${key ? "✓" : "—"}
${actions || "—"}
`); } const vaultRows = []; for (const v of vaults) { @@ -38,9 +56,11 @@ async function teamCard(team, uid) {
${esc(team.name)} /${esc(team.slug)}${me ? esc(me.role) : "member"}
Members
- ${memberRows.join("")}
EmailRoleStatusKey
+
${memberRows.join("")}
EmailPermissionStatusKeyActions
${canInvite ? `
${CSRF} -
` : ""} + + ${me.role === "owner" ? `` : ``} + ` : ""}
Vaults
${vaults.length ? `${vaultRows.join("")}
VaultSecretsYour access
` : `

No vaults yet — create one from the CLI: logicsrc teams push ${esc(team.slug)} <project> <env>

`} @@ -54,9 +74,28 @@ export async function dashboardHandler(req, res) { for (const t of teams) cards += await teamCard(t, uid); cards = cards.split(CSRF).join(csrfInput(req)); + const notices = { + "member-updated": "Member permissions updated.", + "member-removed": "Team member removed.", + "invite-resent": "Invite replaced with a new key.", + }; + const errors = { + "already-member": "That person is already an active team member.", + "bad-email": "Enter a valid email address.", + "bad-role": "Choose a valid permission.", + "last-owner": "A team must keep at least one active owner.", + "member-not-found": "That team member no longer exists.", + "not-allowed": "You do not have permission to make that change.", + }; + const ok = notices[String(req.query.ok || "")]; + const err = errors[String(req.query.err || "")]; + const rotation = req.query.rotation === "1"; + const body = `${appBar(req)}

Your teams

${teams.length}
+ ${ok ? `
${esc(ok)}${rotation ? " Vault grants were revoked; rotate affected vault keys before re-adding this person." : ""}
` : ""} + ${err ? `
${esc(err)}
` : ""} ${CLI_HINT(requestOrigin(req, config.origin))} ${cards || `
You're not on any teams yet. Create one below or accept an invite.
`}
New team
@@ -83,32 +122,81 @@ pagesRouter.post("/teams", requireAuth, async (req, res) => { pagesRouter.post("/teams/:slug/invite", requireAuth, async (req, res) => { const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [req.params.slug]); const me = team && await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ?`, [team.id, req.user.id]); - if (!team || !me || (me.role !== "owner" && me.role !== "admin")) return res.redirect("/dashboard?err=not-allowed"); - const email = String(req.body.email || "").trim().toLowerCase(); - if (!email) return res.redirect("/dashboard"); - const now = Date.now(); - if (!(await get(`SELECT 1 FROM credshare_members WHERE team_id = ? AND email = ?`, [team.id, email]))) { - const u = await get(`SELECT id FROM users WHERE email = ?`, [email]); - await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, invited_by, created_at) VALUES (?,?,?,?,?,?,?,?)`, - [id(), team.id, u?.id ?? null, email, "member", "invited", req.user.id, now]); + if (!team || !me) return res.redirect("/dashboard?err=not-allowed"); + try { + const result = await issueTeamInvite({ + team, + actor: { ...me, email: req.user.email }, + email: req.body.email, + role: req.body.role + }); + return res.redirect("/teams/accept?token=" + encodeURIComponent(result.token) + "&shared=1"); + } catch (error) { + if (error instanceof TeamMemberError) return res.redirect("/dashboard?err=" + encodeURIComponent(error.code)); + throw error; + } +}); + +async function teamMemberContext(req) { + const team = await get(`SELECT * FROM credshare_teams WHERE slug = ?`, [req.params.slug]); + const actor = team && await get(`SELECT * FROM credshare_members WHERE team_id = ? AND user_id = ? AND status = 'active'`, [team.id, req.user.id]); + return team && actor ? { team, actor } : null; +} + +pagesRouter.post("/teams/:slug/members/:memberId/resend", requireAuth, async (req, res) => { + const ctx = await teamMemberContext(req); + const target = ctx && await get(`SELECT * FROM credshare_members WHERE id = ? AND team_id = ?`, [req.params.memberId, ctx.team.id]); + if (!ctx || !target || target.status !== "invited") return res.redirect("/dashboard?err=member-not-found"); + try { + const result = await issueTeamInvite({ + team: ctx.team, + actor: { ...ctx.actor, email: req.user.email }, + email: target.email, + role: target.role + }); + return res.redirect("/teams/accept?token=" + encodeURIComponent(result.token) + "&shared=1&resent=1"); + } catch (error) { + if (error instanceof TeamMemberError) return res.redirect("/dashboard?err=" + encodeURIComponent(error.code)); + throw error; + } +}); + +pagesRouter.post("/teams/:slug/members/:memberId/role", requireAuth, async (req, res) => { + const ctx = await teamMemberContext(req); + if (!ctx) return res.redirect("/dashboard?err=not-allowed"); + try { + await changeMemberRole({ team: ctx.team, actor: ctx.actor, memberId: req.params.memberId, role: req.body.role }); + return res.redirect("/dashboard?ok=member-updated"); + } catch (error) { + if (error instanceof TeamMemberError) return res.redirect("/dashboard?err=" + encodeURIComponent(error.code)); + throw error; + } +}); + +pagesRouter.post("/teams/:slug/members/:memberId/delete", requireAuth, async (req, res) => { + const ctx = await teamMemberContext(req); + if (!ctx) return res.redirect("/dashboard?err=not-allowed"); + try { + const result = await removeTeamMember({ team: ctx.team, actor: ctx.actor, memberId: req.params.memberId }); + return res.redirect(`/dashboard?ok=member-removed${result.rotationRequired ? "&rotation=1" : ""}`); + } catch (error) { + if (error instanceof TeamMemberError) return res.redirect("/dashboard?err=" + encodeURIComponent(error.code)); + throw error; } - const tok = token(24); - await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`, - [id(), team.id, email, "member", sha256(tok), req.user.id, now + 7 * 864e5, now]); - res.redirect("/teams/accept?token=" + encodeURIComponent(tok) + "&shared=1"); }); // ---- accept invite ---- pagesRouter.get("/teams/accept", requireAuth, (req, res) => { const tok = String(req.query.token || ""); const shared = req.query.shared; + const resent = req.query.resent; const err = req.query.err; const body = `${appBar(req)}

Accept team invite

${err ? `
${esc(String(err).replace(/-/g, " "))}
` : ""} - ${shared ? `
Invite created. Share this link with the teammate, or accept below if it's for you.
` : ""} + ${shared ? `
${resent ? "Invite replaced. The previous key is invalid; share this new link with the teammate." : "Invite created. Share this link with the teammate, or accept below if it's for you."}
` : ""}
${csrfInput(req)} diff --git a/apps/pwa/test/team-members.test.mjs b/apps/pwa/test/team-members.test.mjs new file mode 100644 index 0000000..6d5fe73 --- /dev/null +++ b/apps/pwa/test/team-members.test.mjs @@ -0,0 +1,170 @@ +// Integration coverage for team-member CRUD, invite-key rotation, and the +// dashboard controls that expose those operations. +process.env.DATABASE_URL = ":memory:"; + +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import express from "express"; + +const here = dirname(fileURLToPath(import.meta.url)); +const { db, run, get } = await import("../src/db.mjs"); +const { sha256 } = await import("../src/lib/crypto.mjs"); +const { credshareRouter } = await import("../src/routes/credshare.mjs"); +const { pagesRouter } = await import("../src/routes/pages.mjs"); + +for (const file of ["001_auth.sql", "002_credshare.sql"]) { + const sql = readFileSync(join(here, "..", "src", "migrations", file), "utf8"); + for (const statement of sql.split(/;\s*$/m).map((s) => s.trim()).filter(Boolean)) await db.execute(statement); +} + +const now = Date.now(); +for (const [id, email] of [ + ["u_owner", "owner@example.com"], + ["u_owner2", "owner2@example.com"], + ["u_admin", "admin@example.com"], + ["u_member", "member@example.com"] +]) await run(`INSERT INTO users (id, email, created_at) VALUES (?,?,?)`, [id, email, now]); + +async function seedTeam(id, slug, members) { + await run(`INSERT INTO credshare_teams (id, slug, name, created_by, created_at) VALUES (?,?,?,?,?)`, [id, slug, slug, "u_owner", now]); + for (const member of members) { + await run(`INSERT INTO credshare_members (id, team_id, user_id, email, role, status, created_at) VALUES (?,?,?,?,?,?,?)`, + [member.id, id, member.userId ?? null, member.email, member.role, member.status, now]); + } +} + +await seedTeam("t_rotate", "rotate", [ + { id: "rotate_owner", userId: "u_owner", email: "owner@example.com", role: "owner", status: "active" }, + { id: "rotate_invited", email: "new@example.com", role: "member", status: "invited" } +]); +await run(`INSERT INTO credshare_invites (id, team_id, email, role, token_hash, created_by, expires_at, created_at) VALUES (?,?,?,?,?,?,?,?)`, + ["old_invite", "t_rotate", "new@example.com", "member", sha256("old-key"), "u_owner", now + 864e5, now]); + +await seedTeam("t_manage", "manage", [ + { id: "manage_owner", userId: "u_owner", email: "owner@example.com", role: "owner", status: "active" }, + { id: "manage_admin", userId: "u_admin", email: "admin@example.com", role: "admin", status: "active" }, + { id: "manage_member", userId: "u_member", email: "member@example.com", role: "member", status: "active" }, + { id: "manage_member2", email: "member2@example.com", role: "member", status: "invited" }, + { id: "manage_invited_admin", email: "future-admin@example.com", role: "admin", status: "invited" } +]); +await run(`INSERT INTO credshare_vaults (id, team_id, name, created_by, created_at) VALUES (?,?,?,?,?)`, ["v_manage", "t_manage", "app--prod", "u_owner", now]); +await run(`INSERT INTO credshare_vault_grants (vault_id, user_id, wrapped_dek, granted_by, created_at) VALUES (?,?,?,?,?)`, ["v_manage", "u_member", "wrapped", "u_owner", now]); + +await seedTeam("t_owners", "owners", [ + { id: "owners_owner", userId: "u_owner", email: "owner@example.com", role: "owner", status: "active" }, + { id: "owners_owner2", userId: "u_owner2", email: "owner2@example.com", role: "owner", status: "active" } +]); + +async function serve(actingUserId) { + const user = await get(`SELECT * FROM users WHERE id = ?`, [actingUserId]); + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: false })); + app.use((req, _res, next) => { + req.user = user; + req.csrfToken = "test-csrf"; + next(); + }); + app.use(credshareRouter); + app.use(pagesRouter); + const server = app.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + return { + async request(method, path, body) { + const response = await fetch(`${base}${path}`, { + method, + headers: body === undefined ? undefined : { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: "manual" + }); + const text = await response.text(); + return { status: response.status, body: response.headers.get("content-type")?.includes("json") ? JSON.parse(text) : text, location: response.headers.get("location") }; + }, + close: () => new Promise((resolve) => server.close(resolve)) + }; +} + +test("resending rotates the invite key and updates its requested permission", async (t) => { + const app = await serve("u_owner"); + t.after(() => app.close()); + + const response = await app.request("POST", "/api/credshare/teams/rotate/invites", { email: "new@example.com", role: "admin" }); + assert.equal(response.status, 201); + assert.equal(response.body.resent, true); + assert.equal(response.body.invite.role, "admin"); + assert.ok(response.body.token); + + assert.equal(await get(`SELECT 1 AS hit FROM credshare_invites WHERE token_hash = ?`, [sha256("old-key")]), null); + assert.equal((await get(`SELECT COUNT(*) AS n FROM credshare_invites WHERE team_id = 't_rotate' AND email = 'new@example.com' AND accepted_at IS NULL`)).n, 1); + assert.equal((await get(`SELECT role FROM credshare_members WHERE id = 'rotate_invited'`)).role, "admin"); + + const oldAccept = await app.request("POST", "/api/credshare/invites/accept", { token: "old-key" }); + assert.equal(oldAccept.status, 404); +}); + +test("owners can change permissions but the final active owner cannot be demoted", async (t) => { + const app = await serve("u_owner"); + t.after(() => app.close()); + + const promoted = await app.request("PATCH", "/api/credshare/teams/manage/members/manage_member", { role: "admin" }); + assert.equal(promoted.status, 200); + assert.equal(promoted.body.member.role, "admin"); + + const lastOwner = await app.request("PATCH", "/api/credshare/teams/manage/members/manage_owner", { role: "admin" }); + assert.equal(lastOwner.status, 409); + assert.equal(lastOwner.body.code, "last-owner"); + + const transferable = await app.request("PATCH", "/api/credshare/teams/owners/members/owners_owner", { role: "member" }); + assert.equal(transferable.status, 200); +}); + +test("admins cannot promote members or manage owners", async (t) => { + const app = await serve("u_admin"); + t.after(() => app.close()); + + const promote = await app.request("PATCH", "/api/credshare/teams/manage/members/manage_member", { role: "admin" }); + assert.equal(promote.status, 403); + const removeOwner = await app.request("DELETE", "/api/credshare/teams/manage/members/manage_owner"); + assert.equal(removeOwner.status, 403); + const removeMember = await app.request("DELETE", "/api/credshare/teams/manage/members/manage_member2"); + assert.equal(removeMember.status, 200); + + const replaceAdminInvite = await app.request("POST", "/api/credshare/teams/manage/invites", { email: "future-admin@example.com", role: "member" }); + assert.equal(replaceAdminInvite.status, 403); +}); + +test("removing a member also removes their vault grants", async (t) => { + // The previous role test promoted manage_member, so the owner performs this + // removal; an admin must not be able to remove a peer admin. + const app = await serve("u_owner"); + t.after(() => app.close()); + + const response = await app.request("DELETE", "/api/credshare/teams/manage/members/manage_member"); + assert.equal(response.status, 200); + assert.equal(response.body.revokedVaultGrants, 1); + assert.equal(response.body.rotationRequired, true); + assert.equal(await get(`SELECT 1 AS hit FROM credshare_members WHERE id = 'manage_member'`), null); + assert.equal(await get(`SELECT 1 AS hit FROM credshare_vault_grants WHERE vault_id = 'v_manage' AND user_id = 'u_member'`), null); + + const readded = await app.request("POST", "/api/credshare/teams/manage/invites", { email: "member@example.com", role: "member" }); + assert.equal(readded.status, 201); + assert.equal(readded.body.resent, false); + assert.equal((await get(`SELECT status FROM credshare_members WHERE team_id = 't_manage' AND email = 'member@example.com'`)).status, "invited"); + assert.equal(await get(`SELECT 1 AS hit FROM credshare_vault_grants WHERE vault_id = 'v_manage' AND user_id = 'u_member'`), null); +}); + +test("dashboard renders permission, resend, and remove controls", async (t) => { + const app = await serve("u_owner"); + t.after(() => app.close()); + + const response = await app.request("GET", "/dashboard"); + assert.equal(response.status, 200); + assert.match(response.body, /aria-label="Permission for new@example\.com"/); + assert.match(response.body, />Resend<\/button>/); + assert.match(response.body, />Remove<\/button>/); + assert.match(response.body, /