Add functionality to restrict or unrestrict users

This commit is contained in:
Konstantin Wohlwend 2026-05-11 18:58:31 -07:00
parent e880df121d
commit 9ff2c13f8d

View File

@ -46,7 +46,7 @@ import { KnownErrors } from "@stackframe/stack-shared";
import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields";
import { fromNow } from "@stackframe/stack-shared/dist/utils/dates";
import { captureError, StackAssertionError, throwErr } from '@stackframe/stack-shared/dist/utils/errors';
import { StackAssertionError, throwErr } from '@stackframe/stack-shared/dist/utils/errors';
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react";
@ -145,7 +145,7 @@ function UserHeader({ user }: UserHeaderProps) {
}] satisfies DesignMenuActionItem[] : [],
{
id: "restriction",
label: "User restriction",
label: getRestrictionActionLabel(user),
onClick: () => { setRestrictionDialogOpen(true); },
},
{
@ -185,6 +185,26 @@ function getRestrictionReasonText(user: ServerUser): string {
}
}
function getRestrictionActionLabel(user: ServerUser): string {
if (user.restrictedByAdmin) {
return "Edit or remove manual restriction";
}
if (user.isRestricted) {
return "Add manual restriction";
}
return "Restrict user";
}
function getManualRestrictionStatusText(user: ServerUser): string {
if (user.restrictedByAdmin) {
return "Restricted by admin";
}
if (user.isRestricted) {
return `Not manually restricted (${getRestrictionReasonText(user)})`;
}
return "Not restricted";
}
// Restriction dialog for editing restriction details
function RestrictionDialog({
user,
@ -195,35 +215,31 @@ function RestrictionDialog({
open: boolean,
onOpenChange: (open: boolean) => void,
}) {
const restrictedByAdmin = (user as any).restrictedByAdmin ?? false;
const restrictedByAdminReason = (user as any).restrictedByAdminReason ?? null;
const restrictedByAdminPrivateDetails = (user as any).restrictedByAdminPrivateDetails ?? null;
const [publicReason, setPublicReason] = useState(restrictedByAdminReason ?? '');
const [privateDetails, setPrivateDetails] = useState(restrictedByAdminPrivateDetails ?? '');
const [publicReason, setPublicReason] = useState(user.restrictedByAdminReason ?? '');
const [privateDetails, setPrivateDetails] = useState(user.restrictedByAdminPrivateDetails ?? '');
const [isSaving, setIsSaving] = useState(false);
// Reset form when dialog opens
const handleOpenChange = (newOpen: boolean) => {
if (newOpen) {
setPublicReason(restrictedByAdminReason ?? '');
setPrivateDetails(restrictedByAdminPrivateDetails ?? '');
setPublicReason(user.restrictedByAdminReason ?? '');
setPrivateDetails(user.restrictedByAdminPrivateDetails ?? '');
}
onOpenChange(newOpen);
};
const handleSaveAndRestrict = async () => {
if (!privateDetails.trim()) {
alert('Please enter the private details for the restriction.');
return;
}
const trimmedPublicReason = publicReason.trim();
const trimmedPrivateDetails = privateDetails.trim();
setIsSaving(true);
try {
await user.update({ restrictedByAdmin: true, restrictedByAdminReason: publicReason.trim() || null, restrictedByAdminPrivateDetails: privateDetails.trim() || null } as any);
await user.update({
restrictedByAdmin: true,
restrictedByAdminReason: trimmedPublicReason.length > 0 ? trimmedPublicReason : null,
restrictedByAdminPrivateDetails: trimmedPrivateDetails.length > 0 ? trimmedPrivateDetails : null,
});
onOpenChange(false);
} catch (error) {
captureError(`user-restriction-save-and-restrict-error`, new StackAssertionError(`Failed to save and restrict user ${user.id}`, { cause: error }));
} finally {
setIsSaving(false);
}
@ -236,7 +252,7 @@ function RestrictionDialog({
restrictedByAdmin: false,
restrictedByAdminReason: null,
restrictedByAdminPrivateDetails: null,
} as any);
});
onOpenChange(false);
} finally {
setIsSaving(false);
@ -249,7 +265,9 @@ function RestrictionDialog({
<DialogHeader>
<DialogTitle>User Restriction</DialogTitle>
<DialogDescription>
Restricted users cannot access your app by default. You can optionally provide a public reason (shown to the user) and private details (for internal notes).
{user.restrictedByAdmin
? "This user is manually restricted. You can update the notes or remove the manual restriction."
: "Use a manual restriction to block this user from accessing your app by default. You can optionally provide a public reason shown to the user."}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 py-4">
@ -263,21 +281,20 @@ function RestrictionDialog({
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Private details (internal only)</label>
<label className="text-sm font-medium">Private details (internal only, optional)</label>
<Textarea
value={privateDetails}
onChange={(e) => setPrivateDetails(e.target.value)}
placeholder="Internal notes, e.g., which sign-up rule triggered"
required
className="min-h-[80px]"
disabled={isSaving}
/>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
{restrictedByAdmin && (
{user.restrictedByAdmin && (
<Button
variant="destructive"
variant="outline"
onClick={handleRemoveRestriction}
disabled={isSaving}
className="sm:mr-auto"
@ -293,7 +310,6 @@ function RestrictionDialog({
Cancel
</Button>
<Button
onClick={handleSaveAndRestrict}
disabled={isSaving}
>
@ -307,44 +323,54 @@ function RestrictionDialog({
// Restriction banner shown at top of page when user is restricted
function RestrictionBanner({ user }: { user: ServerUser }) {
const [restrictionDialogOpen, setRestrictionDialogOpen] = useState(false);
if (!user.isRestricted) return null;
const restrictedByAdmin = (user as any).restrictedByAdmin ?? false;
const restrictedByAdminReason = (user as any).restrictedByAdminReason ?? null;
const restrictedByAdminPrivateDetails = (user as any).restrictedByAdminPrivateDetails ?? null;
const reasonText = getRestrictionReasonText(user);
return (
<Alert variant="destructive" className="mb-4">
<ProhibitIcon size={16} />
<AlertTitle>This user is currently restricted</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-2">
Restricted users cannot access your app by default. This user is restricted because: <strong>{reasonText}</strong>.
</p>
{user.restrictedReason?.type === 'email_not_verified' && (
<p className="text-sm opacity-80">
The user needs to verify their email address to remove this restriction.
<>
<Alert variant="destructive" className="mb-4">
<ProhibitIcon size={16} />
<AlertTitle>This user is currently restricted</AlertTitle>
<AlertDescription className="mt-2">
<p className="mb-2">
Restricted users cannot access your app by default. This user is restricted because: <strong>{reasonText}</strong>.
</p>
)}
{user.restrictedReason?.type === 'anonymous' && (
<p className="text-sm opacity-80">
Anonymous users must sign up with credentials to remove this restriction.
</p>
)}
{user.restrictedReason?.type === 'restricted_by_administrator' && (
<div className="text-sm opacity-80">
<p>This user was manually restricted by an administrator.</p>
{restrictedByAdminReason && (
<p className="mt-1"><strong>Public reason:</strong> {restrictedByAdminReason}</p>
)}
{restrictedByAdminPrivateDetails && (
<p className="mt-1"><strong>Private details:</strong> {restrictedByAdminPrivateDetails}</p>
)}
</div>
)}
</AlertDescription>
</Alert>
{user.restrictedReason?.type === 'email_not_verified' && (
<p className="text-sm opacity-80">
The user needs to verify their email address to remove this restriction.
</p>
)}
{user.restrictedReason?.type === 'anonymous' && (
<p className="text-sm opacity-80">
Anonymous users must sign up with credentials to remove this restriction.
</p>
)}
{user.restrictedReason?.type === 'restricted_by_administrator' && (
<div className="text-sm opacity-80">
<p>This user was manually restricted by an administrator.</p>
{user.restrictedByAdminReason && (
<p className="mt-1"><strong>Public reason:</strong> {user.restrictedByAdminReason}</p>
)}
{user.restrictedByAdminPrivateDetails && (
<p className="mt-1"><strong>Private details:</strong> {user.restrictedByAdminPrivateDetails}</p>
)}
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setRestrictionDialogOpen(true)}
className="mt-3"
>
{getRestrictionActionLabel(user)}
</Button>
</AlertDescription>
</Alert>
<RestrictionDialog user={user} open={restrictionDialogOpen} onOpenChange={setRestrictionDialogOpen} />
</>
);
}
@ -413,7 +439,21 @@ function UserDetails({ user }: { user: ServerUser }) {
}
function FraudSection({ user }: { user: ServerUser }) {
const [restrictionDialogOpen, setRestrictionDialogOpen] = useState(false);
const items = useMemo<DesignEditableGridItem[]>(() => [
{
type: "custom",
icon: <ProhibitIcon size={14} />,
name: "Manual restriction",
children: (
<span className={cn(
"text-sm",
user.restrictedByAdmin ? "font-medium text-destructive" : "text-muted-foreground",
)}>
{getManualRestrictionStatusText(user)}
</span>
),
},
{
type: "text",
icon: <ShieldIcon size={14} />,
@ -463,15 +503,25 @@ function FraudSection({ user }: { user: ServerUser }) {
return (
<section className="flex flex-col gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Fraud
</h2>
<div className="flex items-center justify-between gap-3">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Fraud
</h2>
<Button
variant={user.restrictedByAdmin ? "outline" : "destructive"}
size="sm"
onClick={() => setRestrictionDialogOpen(true)}
>
{getRestrictionActionLabel(user)}
</Button>
</div>
<DesignEditableGrid
items={items}
columns={2}
size="sm"
deferredSave={false}
/>
<RestrictionDialog user={user} open={restrictionDialogOpen} onOpenChange={setRestrictionDialogOpen} />
</section>
);
}
@ -1350,7 +1400,7 @@ const ACTIVITY_WEEKDAY_LABELS = [
] as const;
// Activity heatmap color ramp. Indexed by 0 = no activity, 1..4 = increasing
// intensity (buckets based on the user's own max activity over the window).
// log-scaled intensity based on the user's own max activity over the window.
// Tailwind needs the exact class strings at build time, so we keep them
// enumerated here rather than building them dynamically.
//
@ -1369,7 +1419,7 @@ const ACTIVITY_COLORS = [
function activityLevel(activity: number, max: number): 0 | 1 | 2 | 3 | 4 {
if (activity <= 0 || max <= 0) return 0;
const intensity = activity / max;
const intensity = Math.log1p(activity) / Math.log1p(max);
if (intensity <= 0.25) return 1;
if (intensity <= 0.5) return 2;
if (intensity <= 0.75) return 3;