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" ? (