Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions apps/web/src/lib/api-invitations.ts
Original file line number Diff line number Diff line change
@@ -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<CreateInvitationResponse>(
`/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<PendingInvitation[]>(
`/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<void>(
`/api/v1/workspaces/${wsId}/invitations/${invitationId}`,
{ method: "DELETE" }
)
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: KEY_INVITATIONS(wsId ?? "_none") })
},
})
}

export async function fetchInviteInfo(token: string): Promise<InviteInfoResponse> {
return apiRequest<InviteInfoResponse>("/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<AcceptInviteResponse> {
return apiRequest<AcceptInviteResponse>("/api/v1/invite/accept", {
method: "POST",
body,
})
}

export function useAcceptInvite() {
return useMutation({
mutationFn: acceptInviteRequest,
})
}
8 changes: 1 addition & 7 deletions apps/web/src/lib/api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -79,6 +81,9 @@ function Root() {
}
}, [isLoading, isAuthenticated, joinWsId])

if (inviteToken !== null) {
return <InviteAcceptPage token={inviteToken} />
}
if (joinWsId !== null) {
return <JoinWorkspaceLanding workspaceId={joinWsId} />
}
Expand All @@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
Expand Down
123 changes: 123 additions & 0 deletions apps/web/src/pages/InviteAcceptPage.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null)

if (infoQ.isLoading) {
return (
<main className="grid min-h-screen place-items-center bg-surface">
<p className="text-sm text-fg-subtle">Loading invitation...</p>
</main>
)
}

if (infoQ.isError || !infoQ.data) {
return (
<main className="grid min-h-screen place-items-center bg-surface">
<div className="w-full max-w-sm space-y-3 rounded-lg border border-line p-6">
<h1 className="text-base font-semibold text-fg">Invalid Invitation</h1>
<p className="text-sm text-fg-subtle">
This invitation link is invalid, expired, or has already been used.
</p>
</div>
</main>
)
}

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 (
<main className="grid min-h-screen place-items-center bg-surface">
<div className="w-full max-w-sm space-y-4 rounded-lg border border-line p-6">
<div className="space-y-1">
<h1 className="text-base font-semibold text-fg">
Join {workspace_name}
</h1>
<p className="text-sm text-fg-subtle">
You've been invited as <span className="font-medium">{role}</span>.
Set a password to activate your account.
</p>
</div>

<form onSubmit={handleSubmit} className="space-y-3">
<div className="space-y-1">
<label className="text-xs font-medium text-fg-subtle">Email</label>
<input
type="email"
value={email}
readOnly
className="w-full rounded-md border border-line bg-surface-subtle px-3 py-2 text-sm text-fg-subtle"
/>
</div>

<div className="space-y-1">
<label className="text-xs font-medium text-fg-subtle">Password</label>
<input
type="password"
value={password}
onChange={(e) => 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"
/>
</div>

<div className="space-y-1">
<label className="text-xs font-medium text-fg-subtle">Confirm Password</label>
<input
type="password"
value={confirm}
onChange={(e) => 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"
/>
</div>

{errMsg && (
<p className="rounded-md border border-danger-border bg-danger-subtle px-3 py-2 text-xs text-danger-emphasis">
{errMsg}
</p>
)}

<button
type="submit"
disabled={acceptMut.isPending}
className="w-full rounded-md bg-fg px-3 py-2 text-sm font-medium text-bg hover:bg-fg/90 disabled:opacity-50"
>
{acceptMut.isPending ? "Joining..." : "Set Password & Join"}
</button>
</form>
</div>
</main>
)
}
Loading