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"
+ ? ``
+ : `${esc(m.role)} `;
+ const canResend = m.status === "invited" && (me.role === "owner" || (me.role === "admin" && m.role === "member"));
+ const actions = [
+ canResend ? `` : "",
+ canManageMember(me, m) ? `` : ""
+ ].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
-
Email Role Status Key ${memberRows.join("")}
+
${canInvite ? `
` : ""}
+
+ ${me.role === "owner" ? `
member admin owner ` : `
`}
+
Invite ` : ""}
Vaults
${vaults.length ? `
Vault Secrets Your access ${vaultRows.join("")}
`
: `
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."}
` : ""}