From eb3a44b5d1658b0917befd9508e4dfe9f2b5c2fd Mon Sep 17 00:00:00 2001 From: yuanhe Date: Thu, 9 Jul 2026 13:27:57 +0800 Subject: [PATCH 1/2] feat: replace temp password with HMAC invite links & protect owner role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace temp-password invitation with HMAC-SHA256 signed invite links - Admin creates invitation → gets a link → shares via IM - Invitee opens link → sets own password → auto-joins workspace - Token: base64url(payload).base64url(hmac), SHA-256 hash stored in DB - CAS pattern for single-use acceptance, 72h TTL - Rate limit on public accept endpoint (10 req/min/IP) - Token passed via POST body to avoid proxy log leakage - Set workspace ID in localStorage on accept for correct routing - Protect owner: cannot edit/remove owner role, cannot promote to owner - Owner role dropdown hidden in UI, remove button hidden for owner - Delete temp password generation code Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/lib/api-invitations.ts | 122 ++++++ apps/web/src/lib/api-types.ts | 8 +- apps/web/src/main.tsx | 13 + apps/web/src/pages/InviteAcceptPage.tsx | 123 ++++++ apps/web/src/pages/admin/MembersPage.tsx | 130 ++---- server/cmd/server/main.go | 15 + server/internal/auth/invite/invite.go | 146 +++++++ server/internal/auth/password/temp.go | 35 -- server/internal/auth/password/temp_test.go | 63 --- server/internal/db/queries/store.sql | 58 +++ server/internal/db/sqlc/models.go | 13 + server/internal/db/sqlc/store.sql.go | 190 +++++++++ server/internal/dev/routes.go | 390 +++++++++++++++--- server/internal/dev/routes_test.go | 16 + server/internal/store/email.go | 4 + server/internal/store/store.go | 265 ++++++++++-- server/internal/store/store_test.go | 30 +- .../000007_workspace_invitations.sql | 27 ++ 18 files changed, 1337 insertions(+), 311 deletions(-) create mode 100644 apps/web/src/lib/api-invitations.ts create mode 100644 apps/web/src/pages/InviteAcceptPage.tsx create mode 100644 server/internal/auth/invite/invite.go delete mode 100644 server/internal/auth/password/temp.go delete mode 100644 server/internal/auth/password/temp_test.go create mode 100644 server/migrations/000007_workspace_invitations.sql diff --git a/apps/web/src/lib/api-invitations.ts b/apps/web/src/lib/api-invitations.ts new file mode 100644 index 00000000..5874a71d --- /dev/null +++ b/apps/web/src/lib/api-invitations.ts @@ -0,0 +1,122 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { apiRequest, noUnreachableRetry } from "./api-client" +import type { MemberRole } from "./api-types" + +export interface CreateInvitationRequest { + email: string + name?: string + role: MemberRole +} + +export interface CreateInvitationResponse { + invitation_id: string + invite_link: string + email: string + role: MemberRole + expires_at: string +} + +export interface PendingInvitation { + id: string + email: string + role: MemberRole + invited_by: string + invited_by_name: string + expires_at: string + created_at: string +} + +export interface InviteInfoResponse { + workspace_name: string + email: string + role: string +} + +export interface AcceptInviteRequest { + token: string + password: string +} + +export interface AcceptInviteResponse { + user_id: string + email: string + workspace_id: string +} + +const KEY_INVITATIONS = (wsId: string) => + ["admin", "invitations", wsId] as const + +export function useCreateInvitation(wsId: string | null) { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (body: CreateInvitationRequest) => { + if (!wsId) throw new Error("no workspace selected") + return apiRequest( + `/api/v1/workspaces/${wsId}/invitations`, + { method: "POST", body } + ) + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: KEY_INVITATIONS(wsId ?? "_none") }) + }, + }) +} + +export function usePendingInvitations(wsId: string | null) { + return useQuery({ + queryKey: KEY_INVITATIONS(wsId ?? "_none"), + queryFn: () => + apiRequest( + `/api/v1/workspaces/${wsId}/invitations`, + { method: "GET" } + ), + enabled: !!wsId, + retry: noUnreachableRetry, + staleTime: 30_000, + }) +} + +export function useRevokeInvitation(wsId: string | null) { + const qc = useQueryClient() + return useMutation({ + mutationFn: async (invitationId: string) => { + if (!wsId) throw new Error("no workspace selected") + return apiRequest( + `/api/v1/workspaces/${wsId}/invitations/${invitationId}`, + { method: "DELETE" } + ) + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: KEY_INVITATIONS(wsId ?? "_none") }) + }, + }) +} + +export async function fetchInviteInfo(token: string): Promise { + return apiRequest("/api/v1/invite/info", { + method: "POST", + body: { token }, + }) +} + +export function useInviteInfo(token: string) { + return useQuery({ + queryKey: ["invite", "info", token], + queryFn: () => fetchInviteInfo(token), + enabled: !!token, + retry: false, + }) +} + +export async function acceptInviteRequest(body: AcceptInviteRequest): Promise { + return apiRequest("/api/v1/invite/accept", { + method: "POST", + body, + }) +} + +export function useAcceptInvite() { + return useMutation({ + mutationFn: acceptInviteRequest, + }) +} diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 1ff7dd59..266632cf 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -860,22 +860,16 @@ export interface ListWorkspaceMembersResponse { * upserted by email. */ export interface AddWorkspaceMemberRequest { email: string - /** Defaults server-side to the local-part of email when omitted. */ name?: string role: MemberRole - /** When true AND the invitee is a brand-new user, the server mints - * a temporary password and returns it once in `temp_password`. */ - invite?: boolean } /** Response from POST .../members: the resulting membership row plus * a `user_created` flag telling the UI whether a fresh user was minted - * or an existing one reused. `temp_password` is present only on - * successful invite flows for brand-new users. */ + * or an existing one reused. */ export interface AddWorkspaceMemberResponse { member: WorkspaceMember user_created: boolean - temp_password?: string } /** Response from DELETE .../members/{userId}: the removed membership row. */ diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index df9b56bb..5e7702f3 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,6 +8,7 @@ import { JoinWorkspaceLanding, popPendingJoinIntent, } from './pages/JoinWorkspaceLanding' +import { InviteAcceptPage } from './pages/InviteAcceptPage' import { bootstrapWorkspace } from './lib/bootstrap' import { AuthProvider, useAuth } from './lib/auth-context' import { useMyWorkspaces } from './lib/api-workspaces' @@ -66,6 +67,7 @@ function Root() { // Matched before the auth-aware admin shell so an unauthenticated user on // this URL hits the landing page directly. const joinWsId = parseJoinWorkspaceId() + const inviteToken = parseInviteToken() // OAuth callback returns to "/" by design. If we stashed an intent before // the OAuth bounce, re-issue it now. Guarded on isAuthenticated to avoid a @@ -79,6 +81,9 @@ function Root() { } }, [isLoading, isAuthenticated, joinWsId]) + if (inviteToken !== null) { + return + } if (joinWsId !== null) { return } @@ -100,6 +105,14 @@ function parseJoinWorkspaceId(): string | null { return id && id.length > 0 ? id : null } +function parseInviteToken(): string | null { + if (typeof window === "undefined") return null + const prefix = "/invite/" + if (!window.location.pathname.startsWith(prefix)) return null + const token = window.location.pathname.slice(prefix.length) + return token.length > 0 ? token : null +} + createRoot(document.getElementById('root')!).render( diff --git a/apps/web/src/pages/InviteAcceptPage.tsx b/apps/web/src/pages/InviteAcceptPage.tsx new file mode 100644 index 00000000..8c1f0d48 --- /dev/null +++ b/apps/web/src/pages/InviteAcceptPage.tsx @@ -0,0 +1,123 @@ +import { useState } from "react" +import { useInviteInfo, useAcceptInvite } from "../lib/api-invitations" +import { setWorkspaceId } from "../lib/workspace" + +export function InviteAcceptPage({ token }: { token: string }) { + const infoQ = useInviteInfo(token) + const acceptMut = useAcceptInvite() + const [password, setPassword] = useState("") + const [confirm, setConfirm] = useState("") + const [errMsg, setErrMsg] = useState(null) + + if (infoQ.isLoading) { + return ( +
+

Loading invitation...

+
+ ) + } + + if (infoQ.isError || !infoQ.data) { + return ( +
+
+

Invalid Invitation

+

+ This invitation link is invalid, expired, or has already been used. +

+
+
+ ) + } + + const { workspace_name, email, role } = infoQ.data + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setErrMsg(null) + if (password !== confirm) { + setErrMsg("Passwords do not match") + return + } + if (password.length < 8) { + setErrMsg("Password must be at least 8 characters") + return + } + try { + const res = await acceptMut.mutateAsync({ token, password }) + setWorkspaceId(res.workspace_id) + window.location.assign("/") + } catch (err) { + setErrMsg(err instanceof Error ? err.message : "Failed to accept invitation") + } + } + + return ( +
+
+
+

+ Join {workspace_name} +

+

+ You've been invited as {role}. + Set a password to activate your account. +

+
+ +
+
+ + +
+ +
+ + setPassword(e.target.value)} + placeholder="Set your password" + required + autoFocus + autoComplete="new-password" + className="w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-fg placeholder:text-fg-faint focus:border-line-strong focus:outline-none focus:ring-1 focus:ring-slate-200" + /> +
+ +
+ + setConfirm(e.target.value)} + placeholder="Confirm password" + required + autoComplete="new-password" + className="w-full rounded-md border border-line bg-surface px-3 py-2 text-sm text-fg placeholder:text-fg-faint focus:border-line-strong focus:outline-none focus:ring-1 focus:ring-slate-200" + /> +
+ + {errMsg && ( +

+ {errMsg} +

+ )} + + +
+
+
+ ) +} diff --git a/apps/web/src/pages/admin/MembersPage.tsx b/apps/web/src/pages/admin/MembersPage.tsx index 0a1a7ebe..e8eb5d23 100644 --- a/apps/web/src/pages/admin/MembersPage.tsx +++ b/apps/web/src/pages/admin/MembersPage.tsx @@ -4,7 +4,7 @@ import { Check, Copy, Inbox, - KeyRound, + Link2, Loader2, MoreHorizontal, Plus, @@ -54,7 +54,7 @@ import { useUpdateWorkspaceMemberRole, useWorkspaceMembers, } from "../../lib/api-members" -import { useBootstrapStatus } from "../../lib/api-bootstrap" +import { useCreateInvitation } from "../../lib/api-invitations" import type { AddWorkspaceMemberRequest, MemberRole, @@ -246,9 +246,8 @@ export function MembersPage() { { setInviteOpen(false) - addWsMut.reset() }} - invite={(body) => addWsMut.mutateAsync(body)} + wsId={wsId} /> )} @@ -376,7 +375,7 @@ function MembersTable({ - {writable && onChangeRole ? ( + {writable && onChangeRole && m.role !== "owner" ? (