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
30 changes: 30 additions & 0 deletions apps/dashboard/app/(dashboard)/gateway/gateway-key-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { useActionState } from "react";
import { AlertCircle } from "lucide-react";
import { ensureGatewayKey, type GatewayKeyState } from "@/app/actions";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";

const initialState: GatewayKeyState = {};

export function GatewayKeyForm({ label, disabled, existingError }: { label: string; disabled: boolean; existingError?: string | null }) {
const [state, action, pending] = useActionState(ensureGatewayKey, initialState);

return (
<div className="border-t p-4">
{state.error && state.error !== existingError && (
<Alert variant="destructive" className="mb-4">
<AlertCircle />
<AlertTitle>Provisioning failed</AlertTitle>
<AlertDescription>{state.error}</AlertDescription>
</Alert>
)}
<form action={action} className="flex justify-end">
<Button type="submit" disabled={disabled || pending}>
{pending ? "Provisioning…" : label}
</Button>
</form>
</div>
);
}
4 changes: 2 additions & 2 deletions apps/dashboard/app/(dashboard)/gateway/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { api, requireIdentity } from "@/lib/api";
import { RefreshButton } from "./refresh-button";
import { GatewayTabs, type GatewayTab } from "./gateway-tabs";
import { ProxyHealthRow } from "./proxy-health-row";
import { ensureGatewayKey } from "@/app/actions";
import { GatewayKeyForm } from "./gateway-key-form";

type GatewayStatus = {
enabled: boolean;
Expand Down Expand Up @@ -248,7 +248,7 @@ export default async function GatewayPage({ searchParams }: { searchParams: Prom
{access?.error && <div className="border-t p-4"><Alert variant="destructive"><AlertCircle /><AlertTitle>Provisioning failed</AlertTitle><AlertDescription>{access.error}</AlertDescription></Alert></div>}
{validationError && <div className="border-t p-4"><Alert><AlertCircle /><AlertTitle>Key verification unavailable</AlertTitle><AlertDescription>{validationError} The stored key was left unchanged.</AlertDescription></Alert></div>}
{access?.status === "invalid" && <div className="border-t p-4"><Alert variant="destructive"><AlertCircle /><AlertTitle>Upstream credential is invalid</AlertTitle><AlertDescription>{access.invalidation_reason ?? "The credential was rejected or revoked by the upstream gateway."} Provision a new credential to restore gateway access.</AlertDescription></Alert></div>}
<form action={ensureGatewayKey} className="flex justify-end border-t p-4"><Button type="submit" disabled={access?.status === "recovering" || Boolean(access?.next_retry_at && new Date(access.next_retry_at).getTime() > Date.now())}>{access?.status === "invalid" ? "Provision new key" : access?.status === "missing" ? "Provision key" : access?.status === "recovering" ? "Recovering…" : "Reconcile key"}</Button></form>
<GatewayKeyForm label={access?.status === "invalid" ? "Provision new key" : access?.status === "missing" ? "Provision key" : access?.status === "recovering" ? "Recovering…" : "Reconcile key"} disabled={access?.status === "recovering" || Boolean(access?.next_retry_at && new Date(access.next_retry_at).getTime() > Date.now())} existingError={access?.error} />
</section>
)}
logs={(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

export function InvitationLinkResult({ email, url }: { email: string; url: string }) {
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
const resetTimer = useRef<number | null>(null);

useEffect(() => () => {
if (resetTimer.current) window.clearTimeout(resetTimer.current);
}, []);

async function copy() {
try {
await navigator.clipboard.writeText(url);
setCopyStatus("copied");
} catch {
setCopyStatus("failed");
}
if (resetTimer.current) window.clearTimeout(resetTimer.current);
resetTimer.current = window.setTimeout(() => setCopyStatus("idle"), 2000);
}

return (
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">Share this link with <strong className="text-foreground">{email}</strong>. The email address is pinned to this invitation.</p>
<div className="grid gap-2">
<Label htmlFor="issued-invitation-url">Invitation link</Label>
<div className="flex gap-2">
<Input id="issued-invitation-url" value={url} readOnly onFocus={(event) => event.currentTarget.select()} />
<Button
type="button"
variant="outline"
size="icon"
onClick={copy}
aria-label={copyStatus === "copied" ? "Invitation link copied" : "Copy invitation link"}
title={copyStatus === "copied" ? "Copied" : "Copy invitation link"}
>
{copyStatus === "copied" ? <Check /> : <Copy />}
</Button>
</div>
{copyStatus === "failed" && (
<p className="text-sm" role="alert">Copy failed. Select and copy the link manually.</p>
)}
</div>
</div>
);
}
47 changes: 47 additions & 0 deletions apps/dashboard/app/(dashboard)/members/invite-member-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"use client";

import { useActionState, useState } from "react";
import { Plus } from "lucide-react";
import { createUser, type InvitationLinkState } from "@/app/actions";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { InvitationLinkResult } from "./invitation-link-result";

const initialState: InvitationLinkState = {};

export function InviteMemberDialog() {
const [open, setOpen] = useState(false);
const [generation, setGeneration] = useState(0);
return (
<Dialog open={open} onOpenChange={(next) => { setOpen(next); if (next) setGeneration((value) => value + 1); }}>
<DialogTrigger render={<Button />}><Plus /> Invite member</DialogTrigger>
{open && <InviteMemberDialogContent key={generation} />}
</Dialog>
);
}

function InviteMemberDialogContent() {
const [state, action, pending] = useActionState(createUser, initialState);

return (
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{state.invitationUrl ? "Share invitation link" : "Create an invitation"}</DialogTitle>
<DialogDescription>{state.invitationUrl ? "Copy this link now; it is not available from the invitation list later." : "Create a pinned-email link to share with the new member."}</DialogDescription>
</DialogHeader>
{state.invitationUrl && state.email ? (
<InvitationLinkResult email={state.email} url={state.invitationUrl} />
) : (
<form action={action} className="grid gap-5">
<div className="grid gap-2"><Label htmlFor="invite-email">Email</Label><Input id="invite-email" name="email" type="email" required autoFocus /></div>
<div className="grid gap-2"><Label>Role</Label><Select name="role" defaultValue="member"><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="member">Member</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent></Select></div>
{state.error && <p role="alert" className="text-sm text-destructive">{state.error}</p>}
<DialogFooter><Button type="submit" disabled={pending}>{pending ? "Creating…" : "Create invitation"}</Button></DialogFooter>
</form>
)}
</DialogContent>
);
}
44 changes: 41 additions & 3 deletions apps/dashboard/app/(dashboard)/members/member-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"use client";

import { useState } from "react";
import { useActionState, useState } from "react";
import { useRouter } from "next/navigation";
import { Ellipsis } from "lucide-react";
import {
cancelInvitation,
deleteUser,
resendInvitation,
regenerateInvitation,
revokeUserSessions,
updateUser,
} from "@/app/actions";
Expand All @@ -14,6 +15,8 @@ import {
type ConfirmationAction,
} from "@/components/confirmation-dialog";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InvitationLinkResult } from "./invitation-link-result";
import {
DropdownMenu,
DropdownMenuContent,
Expand Down Expand Up @@ -99,16 +102,51 @@ export function InvitationActions({ invitation }: {
invitation: { id: string; email: string; status: string };
}) {
const [confirmation, setConfirmation] = useState<ConfirmationAction>();
const [regenerateOpen, setRegenerateOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`Actions for ${invitation.email}`} />}><Ellipsis /></DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
{(invitation.status === "pending" || invitation.status === "expired") && <ActionItem action={resendInvitation} fields={{ invitation_id: invitation.id }}>Resend</ActionItem>}
{(invitation.status === "pending" || invitation.status === "expired") && <ConfirmationItem onSelect={() => setRegenerateOpen(true)}>Regenerate invitation link</ConfirmationItem>}
{invitation.status === "pending" && <ConfirmationItem destructive onSelect={() => setConfirmation({ action: cancelInvitation, fields: { invitation_id: invitation.id }, title: "Cancel invitation?", description: `${invitation.email} will no longer be able to accept this invitation.`, confirmLabel: "Cancel invitation", destructive: true })}>Cancel</ConfirmationItem>}
</DropdownMenuContent>
</DropdownMenu>
<ConfirmationDialog confirmation={confirmation} open={Boolean(confirmation)} onOpenChange={(open) => { if (!open) setConfirmation(undefined); }} />
{regenerateOpen && <RegenerateInvitationDialog invitation={invitation} open onOpenChange={setRegenerateOpen} />}
</>
);
}

function RegenerateInvitationDialog({ invitation, open, onOpenChange }: {
invitation: { id: string; email: string };
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const [state, action, pending] = useActionState(regenerateInvitation, {});

function handleOpenChange(next: boolean) {
if (pending) return;
onOpenChange(next);
if (!next && state.invitationUrl) router.refresh();
}

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{state.invitationUrl ? "Share replacement invitation link" : "Regenerate invitation link?"}</DialogTitle>
<DialogDescription>{state.invitationUrl ? "Copy the replacement link now; it will not be shown in the invitation list." : `The previous link for ${invitation.email} will stop working immediately.`}</DialogDescription>
</DialogHeader>
{state.invitationUrl && state.email ? <InvitationLinkResult email={state.email} url={state.invitationUrl} /> : (
<form action={action}>
<input type="hidden" name="invitation_id" value={invitation.id} />
{state.error && <p role="alert" className="mb-4 text-sm text-destructive">{state.error}</p>}
<DialogFooter><Button type="button" variant="outline" disabled={pending} onClick={() => handleOpenChange(false)}>Cancel</Button><Button type="submit" disabled={pending}>{pending ? "Regenerating…" : "Regenerate link"}</Button></DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
);
}
12 changes: 7 additions & 5 deletions apps/dashboard/app/(dashboard)/members/members-tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import { resolveMembersTab } from "@/lib/members-view";

export function MembersTabs({
managed,
invitationsEnabled,
memberCount,
invitationCount,
members,
invitations,
identity,
}: {
managed: boolean;
invitationsEnabled: boolean;
memberCount: number;
invitationCount: number;
members: ReactNode;
Expand All @@ -24,12 +26,12 @@ export function MembersTabs({
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
const tab = resolveMembersTab(searchParams.get("tab") ?? "", managed);
const tab = resolveMembersTab(searchParams.get("tab") ?? "", managed, invitationsEnabled);

function selectTab(next: string | number) {
if (
(next !== "members" && next !== "invited" && next !== "identity") ||
(managed && next === "invited") ||
(!invitationsEnabled && next === "invited") ||
(!managed && next === "identity")
) {
return;
Expand All @@ -49,9 +51,9 @@ export function MembersTabs({
<TabsTrigger value="members" className="flex-none px-2">
Members <Badge variant="secondary">{memberCount}</Badge>
</TabsTrigger>
<TabsTrigger value="invited" disabled={managed} className="flex-none px-2">
{invitationsEnabled && <TabsTrigger value="invited" className="flex-none px-2">
Invited <Badge variant="secondary">{invitationCount}</Badge>
</TabsTrigger>
</TabsTrigger>}
{managed && (
<TabsTrigger value="identity" className="flex-none px-2">
Identity &amp; provisioning
Expand All @@ -60,7 +62,7 @@ export function MembersTabs({
</TabsList>
</div>
<TabsContent value="members">{members}</TabsContent>
{!managed && <TabsContent value="invited">{invitations}</TabsContent>}
{invitationsEnabled && <TabsContent value="invited">{invitations}</TabsContent>}
{managed && <TabsContent value="identity">{identity}</TabsContent>}
</Tabs>
);
Expand Down
33 changes: 7 additions & 26 deletions apps/dashboard/app/(dashboard)/members/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { api, requireAdminIdentity } from "../../../lib/api";
import Link from "next/link";
import { redirect } from "next/navigation";
import { createUser } from "../../actions";
import { identityConfig } from "../../../lib/identity-config";
import { buildManagedIdentityOverview } from "../../../lib/identity-overview";
import {
Expand All @@ -12,7 +11,7 @@ import {
} from "../../../lib/members-view";
import { InvitationActions, MemberActions } from "./member-actions";
import { MembersTabs } from "./members-tabs";
import { Info, Plus, Search, SlidersHorizontal, X } from "lucide-react";
import { Info, Search, SlidersHorizontal, X } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -43,6 +42,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { TablePaginationFooter } from "@/components/table-pagination-footer";
import { InviteMemberDialog } from "./invite-member-dialog";

type SearchParams = Record<string, string | string[] | undefined>;
const allowedPageSizes = new Set([25, 50, 75, 100]);
Expand Down Expand Up @@ -198,12 +198,14 @@ export default async function Members({
identityStatus?.auth_mode === "oidc" &&
identityStatus.scim.configured,
);
const invitationsEnabled = configuredIdentity.mode !== "oidc";
const requestedSize = positiveInteger(queryValue(raw, "per_page"), 25);
const perPage = allowedPageSizes.has(requestedSize) ? requestedSize : 25;
const requestedTab = queryValue(raw, "tab");
const activeTab: MembersTab = resolveMembersTab(
requestedTab,
managedWorkspace,
invitationsEnabled,
);
const requestedPage = positiveInteger(queryValue(raw, "page"), 1);
const filters = resolveMemberFilters({
Expand Down Expand Up @@ -258,31 +260,9 @@ export default async function Members({
const invitationActiveFilters = memberActiveFilters.filter((filter) => filter.key === "q" || filter.key === "role");
return (
<div className="flex flex-col gap-6">
{!managedWorkspace && (
{invitationsEnabled && (
<div className="dashboard-page-header flex flex-wrap items-start justify-between gap-4">
<Dialog>
<DialogTrigger render={<Button />}><Plus /> Invite member</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Invite a member</DialogTitle>
<DialogDescription>Send an invitation to join this organization.</DialogDescription>
</DialogHeader>
<form action={createUser} className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="invite-email">Email</Label>
<Input id="invite-email" name="email" type="email" required autoFocus />
</div>
<div className="grid gap-2">
<Label>Role</Label>
<Select name="role" defaultValue="member">
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="member">Member</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
</Select>
</div>
<DialogFooter><Button type="submit">Send invitation</Button></DialogFooter>
</form>
</DialogContent>
</Dialog>
<InviteMemberDialog />
</div>
)}
{managedWorkspace && identityOverview && (
Expand All @@ -297,6 +277,7 @@ export default async function Members({
)}
<MembersTabs
managed={managedWorkspace}
invitationsEnabled={invitationsEnabled}
memberCount={users.total}
invitationCount={invited.total}
members={(
Expand Down
Loading
Loading