Files
teatea-pension/modules/settings/components/OrganizationDetailClient.tsx

245 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { FormEvent, useState } from "react";
import { Copy, LinkIcon, Save, Send } from "lucide-react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Table } from "@/components/ui/table";
import type { ApiResult } from "@/modules/core/server/api";
import type { Organization, OrganizationInvitation, RoleDefinition } from "@/modules/core/types";
import { getInviteHref, OrganizationInviteDialog } from "@/modules/settings/components/OrganizationInviteDialog";
type OrganizationDetailClientProps = {
invitations: OrganizationInvitation[];
organization: Organization;
roles: RoleDefinition[];
};
export function OrganizationDetailClient({
invitations,
organization,
roles,
}: OrganizationDetailClientProps): React.ReactElement {
const router = useRouter();
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [settingsMessage, setSettingsMessage] = useState("");
const [isPending, setIsPending] = useState(false);
async function handleSettingsSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setSettingsMessage("");
setIsPending(true);
const formData = new FormData(event.currentTarget);
const response = await fetch(`/api/organizations/${organization.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
registrationEnabled: formData.get("registrationEnabled") === "on",
oidcEnabled: formData.get("oidcEnabled") === "on",
oidcIssuerUrl: String(formData.get("oidcIssuerUrl") ?? ""),
oidcClientId: String(formData.get("oidcClientId") ?? ""),
oidcClientSecret: String(formData.get("oidcClientSecret") ?? ""),
oidcScopes: String(formData.get("oidcScopes") ?? ""),
oidcRedirectUri: String(formData.get("oidcRedirectUri") ?? ""),
oidcAvatarClaim: String(formData.get("oidcAvatarClaim") ?? ""),
oidcAutoProvision: formData.get("oidcAutoProvision") === "on",
}),
});
const result = (await response.json()) as ApiResult<{ organization: Organization }>;
setIsPending(false);
if (!result.success) {
setSettingsMessage(result.reason);
return;
}
setSettingsMessage(result.reason);
router.refresh();
}
return (
<div className="grid gap-5 xl:grid-cols-[0.95fr_1.05fr]">
<Card>
<CardHeader>
<CardTitle>访</CardTitle>
<CardDescription>OIDC </CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-4" onSubmit={handleSettingsSubmit}>
<div className="rounded-md border p-3 text-sm">
<Checkbox
controlFirst={false}
defaultChecked={organization.registrationEnabled}
label={
<span className="min-w-0">
<span className="block font-medium"></span>
<span className="block text-xs text-muted-foreground">使</span>
</span>
}
name="registrationEnabled"
/>
</div>
<div className="rounded-md border p-3 text-sm">
<Checkbox
controlFirst={false}
defaultChecked={organization.oidcEnabled}
label={
<span className="min-w-0">
<span className="block font-medium"> OIDC </span>
<span className="block text-xs text-muted-foreground"> OIDC </span>
</span>
}
name="oidcEnabled"
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<Input
defaultValue={organization.oidcIssuerUrl}
label="Issuer URL"
name="oidcIssuerUrl"
placeholder="https://idp.example.com"
/>
<Input defaultValue={organization.oidcClientId} label="Client ID" name="oidcClientId" />
</div>
<Input
label="Client Secret"
name="oidcClientSecret"
placeholder={organization.oidcHasClientSecret ? "已配置,留空则不修改" : "未配置"}
type="password"
/>
<div className="grid gap-3 md:grid-cols-2">
<Input
defaultValue={organization.oidcScopes}
label="Scopes"
name="oidcScopes"
placeholder="openid profile email"
/>
<Input
defaultValue={organization.oidcRedirectUri}
label="Redirect URI"
name="oidcRedirectUri"
placeholder="/api/auth/oidc/callback"
/>
</div>
<Input
defaultValue={organization.oidcAvatarClaim}
label="Avatar Claim"
name="oidcAvatarClaim"
placeholder="picture"
/>
<div className="rounded-md border p-3 text-sm">
<Checkbox
controlFirst={false}
defaultChecked={organization.oidcAutoProvision}
label={
<span className="min-w-0">
<span className="block font-medium">OIDC </span>
<span className="block text-xs text-muted-foreground"> OIDC </span>
</span>
}
name="oidcAutoProvision"
/>
</div>
{settingsMessage ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{settingsMessage}
</p>
) : null}
<Button className="w-fit" disabled={isPending} type="submit">
<Save className="size-4" aria-hidden="true" />
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Button onClick={() => setIsInviteOpen(true)} type="button">
<Send className="size-4" aria-hidden="true" />
</Button>
</CardHeader>
<CardContent className="grid gap-4">
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[820px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium">使</Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body className="divide-y bg-card">
{invitations.map((invitation) => (
<Table.Row className="hover:bg-secondary/45" key={invitation.id}>
<Table.Cell className="px-4 py-3">{invitation.email || "通用邀请"}</Table.Cell>
<Table.Cell className="px-4 py-3">{invitation.roleLabel}</Table.Cell>
<Table.Cell className="px-4 py-3">
<Badge variant={invitation.status === "active" ? "success" : "secondary"}>{invitation.status}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{invitation.usedCount} / {invitation.maxUses}
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{new Date(invitation.expiresAt).toLocaleString("zh-CN")}
</Table.Cell>
<Table.Cell className="px-4 py-3 text-right">
<Button
onClick={() => void navigator.clipboard.writeText(getInviteHref(invitation.token))}
size="sm"
type="button"
variant="outline"
>
<Copy className="size-4" aria-hidden="true" />
</Button>
</Table.Cell>
</Table.Row>
))}
{invitations.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
) : null}
</Table.Body>
</Table>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LinkIcon className="size-4" aria-hidden="true" />
使 `/register?invite=...`使
</div>
</CardContent>
</Card>
<OrganizationInviteDialog
onClose={() => setIsInviteOpen(false)}
open={isInviteOpen}
organization={organization}
roles={roles}
/>
</div>
);
}