1"use client";
2
3
21import { useState } from "react";
22import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
23import { orgAdminApi, type OrgPolicy } from "@/lib/org-admin";
24import { isApiError } from "@/lib/api";
25import { toast } from "@/stores/ui-store";
26import { Section, Field, inputCls } from "@/components/settings/settings-shell";
27import { ConfirmDialog } from "@/components/ui/confirm-dialog";
28
29const TOGGLES: { key: keyof OrgPolicy; label: string; description: string }[] = [
30 { key: "require2FA", label: "Require 2FA", description: "Members must complete two-factor authentication at login." },
31 { key: "ssoEnforced", label: "Enforce SSO", description: "Password login is blocked when an SSO provider is configured." },
32 { key: "disableGuestPublicShare", label: "Disable guest public shares", description: "Guests cannot create or open public shares (403)." },
33 { key: "allowExternalEmbeds", label: "Allow external embeds", description: "Pages can be embedded on external sites (the embed preview enforces this)." },
34];
35
36export function AdminPoliciesTab({ orgId }: { orgId: string }) {
37 const qc = useQueryClient();
38 const policy = useQuery({
39 queryKey: ["orgs", orgId, "admin", "policy"],
40 queryFn: () => orgAdminApi.policy(orgId),
41 retry: false,
42 });
43
44 const patch = useMutation({
45 mutationFn: (body: Partial<OrgPolicy>) => orgAdminApi.policyPatch(orgId, body),
46 onSuccess: () => {
47 toast("success", "Policy updated");
48 qc.invalidateQueries({ queryKey: ["orgs", orgId, "admin", "policy"] });
49 },
50 onError: (e) => toast("error", "Could not update policy", isApiError(e) ? e.message : undefined),
51 });
52
53 if (policy.isLoading || !policy.data) {
54 return <p className="text-sm text-muted-foreground" role="status">Loading policy…</p>;
55 }
56 if (policy.isError) return <p className="text-sm text-destructive" role="alert">Could not load the policy.</p>;
57
58
59 return <PolicyForm key={JSON.stringify(policy.data.policy)} p={policy.data} patch={patch} />;
60}
61
62function PolicyForm({
63 p,
64 patch,
65}: {
66 p: { policy: OrgPolicy; effective: { maxFileSizeMB: number; billingPlan: string } };
67 patch: { isPending: boolean; mutate: (body: Partial<OrgPolicy>) => void };
68}) {
69 const [confirm2FA, setConfirm2FA] = useState(false);
70 const [maxFile, setMaxFile] = useState<number | null>(p.policy.maxFileSizeMB);
71 const [sessionLife, setSessionLife] = useState<number | null>(p.policy.sessionLifetimeHours);
72
73 return (
74 <div className="space-y-6">
75 <Section title="Security" description="These are enforced server-side at their respective API points.">
76 <div className="space-y-4">
77 {TOGGLES.map((t) => (
78 <div key={t.key} className="flex items-start justify-between gap-4">
79 <div>
80 <div className="text-sm font-medium">{t.label}</div>
81 <div className="text-xs text-muted-foreground">{t.description}</div>
82 </div>
83 <button
84 type="button"
85 role="switch"
86 aria-checked={Boolean(p.policy[t.key])}
87 aria-label={t.label}
88 disabled={patch.isPending}
89 onClick={() => {
90 if (t.key === "require2FA" && !p.policy.require2FA) {
91 setConfirm2FA(true);
92 return;
93 }
94 patch.mutate({ [t.key]: !p.policy[t.key] } as Partial<OrgPolicy>);
95 }}
96 className={
97 "relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:opacity-50 " +
98 (p.policy[t.key] ? "bg-primary" : "bg-muted-foreground/30")
99 }
100 >
101 <span
102 className={
103 "absolute top-0.5 size-4 rounded-full bg-background transition-transform " +
104 (p.policy[t.key] ? "translate-x-4" : "translate-x-0.5")
105 }
106 />
107 </button>
108 </div>
109 ))}
110 </div>
111 </Section>
112
113 <Section
114 title="Limits"
115 description={`Plan: ${p.effective.billingPlan} (the server caps the effective max file size at the plan limit).`}
116 >
117 <div className="grid gap-4 sm:grid-cols-2">
118 <Field label="Max file size (MB)" hint={`Effective: ${p.effective.maxFileSizeMB} MB (plan-capped)`}>
119 <div className="flex gap-2">
120 <input
121 type="number"
122 min={1}
123 max={10240}
124 value={maxFile ?? ""}
125 onChange={(e) => setMaxFile(e.target.value === "" ? null : Number(e.target.value))}
126 className={inputCls}
127 aria-label="Max file size in megabytes"
128 />
129 <button
130 type="button"
131 onClick={() => patch.mutate({ maxFileSizeMB: maxFile ?? null })}
132 disabled={patch.isPending || maxFile === p.policy.maxFileSizeMB}
133 className="rounded-md border border-input px-3 py-1.5 text-sm font-medium hover:bg-muted disabled:opacity-50"
134 >
135 Save
136 </button>
137 </div>
138 </Field>
139 <Field label="Session lifetime (hours)" hint="1–720. The effective lifetime is the MAX across your orgs.">
140 <div className="flex gap-2">
141 <input
142 type="number"
143 min={1}
144 max={720}
145 value={sessionLife ?? ""}
146 onChange={(e) => setSessionLife(e.target.value === "" ? null : Number(e.target.value))}
147 className={inputCls}
148 aria-label="Session lifetime in hours"
149 />
150 <button
151 type="button"
152 onClick={() => patch.mutate({ sessionLifetimeHours: sessionLife ?? null })}
153 disabled={patch.isPending || sessionLife === p.policy.sessionLifetimeHours}
154 className="rounded-md border border-input px-3 py-1.5 text-sm font-medium hover:bg-muted disabled:opacity-50"
155 >
156 Save
157 </button>
158 </div>
159 </Field>
160 </div>
161 </Section>
162
163 <ConfirmDialog
164 open={confirm2FA}
165 title="Require 2FA for all members?"
166 description="Members without 2FA set up will be prompted on their next login. Existing sessions remain valid until they expire."
167 confirmLabel="Require 2FA"
168 destructive
169 busy={patch.isPending}
170 onConfirm={() => {
171 setConfirm2FA(false);
172 patch.mutate({ require2FA: true });
173 }}
174 onClose={() => setConfirm2FA(false)}
175 />
176 </div>
177 );
178}
179