feat(credentials): manage team members and rotate invites
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run

This commit is contained in:
Anthony Ettinger 2026-08-04 19:16:02 +00:00
parent fd253b0485
commit e4723f31b1
10 changed files with 531 additions and 61 deletions

View file

@ -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)}

View file

@ -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 };
}