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

228 lines
10 KiB
TypeScript
Raw 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}>
<label className="flex items-center justify-between gap-4 rounded-md border p-3 text-sm">
<span>
<span className="block font-medium"></span>
<span className="block text-xs text-muted-foreground">使</span>
</span>
<Checkbox
aria-label="开放公开注册"
defaultChecked={organization.registrationEnabled}
name="registrationEnabled"
/>
</label>
<label className="flex items-center justify-between gap-4 rounded-md border p-3 text-sm">
<span>
<span className="block font-medium"> OIDC </span>
<span className="block text-xs text-muted-foreground"> OIDC </span>
</span>
<Checkbox aria-label="启用 OIDC 登录" defaultChecked={organization.oidcEnabled} name="oidcEnabled" />
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="grid gap-2 text-sm">
<span className="font-medium">Issuer URL</span>
<Input defaultValue={organization.oidcIssuerUrl} name="oidcIssuerUrl" placeholder="https://idp.example.com" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium">Client ID</span>
<Input defaultValue={organization.oidcClientId} name="oidcClientId" />
</label>
</div>
<label className="grid gap-2 text-sm">
<span className="font-medium">Client Secret</span>
<Input
name="oidcClientSecret"
placeholder={organization.oidcHasClientSecret ? "已配置,留空则不修改" : "未配置"}
type="password"
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="grid gap-2 text-sm">
<span className="font-medium">Scopes</span>
<Input defaultValue={organization.oidcScopes} name="oidcScopes" placeholder="openid profile email" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium">Redirect URI</span>
<Input defaultValue={organization.oidcRedirectUri} name="oidcRedirectUri" placeholder="/api/auth/oidc/callback" />
</label>
</div>
<label className="grid gap-2 text-sm">
<span className="font-medium">Avatar Claim</span>
<Input defaultValue={organization.oidcAvatarClaim} name="oidcAvatarClaim" placeholder="picture" />
</label>
<label className="flex items-center justify-between gap-4 rounded-md border p-3 text-sm">
<span>
<span className="block font-medium">OIDC </span>
<span className="block text-xs text-muted-foreground"> OIDC </span>
</span>
<Checkbox
aria-label="OIDC 自动开户"
defaultChecked={organization.oidcAutoProvision}
name="oidcAutoProvision"
/>
</label>
{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-[720px] 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 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">
{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={5}>
</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>
);
}