Skip to content

Commit 9ff2c13

Browse files
committed
Add functionality to restrict or unrestrict users
1 parent e880df1 commit 9ff2c13

1 file changed

Lines changed: 111 additions & 61 deletions

File tree

  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]

apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/users/[userId]/page-client.tsx

Lines changed: 111 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ import { KnownErrors } from "@stackframe/stack-shared";
4646
import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
4747
import { normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields";
4848
import { fromNow } from "@stackframe/stack-shared/dist/utils/dates";
49-
import { captureError, StackAssertionError, throwErr } from '@stackframe/stack-shared/dist/utils/errors';
49+
import { StackAssertionError, throwErr } from '@stackframe/stack-shared/dist/utils/errors';
5050
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
5151
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
5252
import { Suspense, useCallback, useMemo, useState, type ReactNode } from "react";
@@ -145,7 +145,7 @@ function UserHeader({ user }: UserHeaderProps) {
145145
}] satisfies DesignMenuActionItem[] : [],
146146
{
147147
id: "restriction",
148-
label: "User restriction",
148+
label: getRestrictionActionLabel(user),
149149
onClick: () => { setRestrictionDialogOpen(true); },
150150
},
151151
{
@@ -185,6 +185,26 @@ function getRestrictionReasonText(user: ServerUser): string {
185185
}
186186
}
187187

188+
function getRestrictionActionLabel(user: ServerUser): string {
189+
if (user.restrictedByAdmin) {
190+
return "Edit or remove manual restriction";
191+
}
192+
if (user.isRestricted) {
193+
return "Add manual restriction";
194+
}
195+
return "Restrict user";
196+
}
197+
198+
function getManualRestrictionStatusText(user: ServerUser): string {
199+
if (user.restrictedByAdmin) {
200+
return "Restricted by admin";
201+
}
202+
if (user.isRestricted) {
203+
return `Not manually restricted (${getRestrictionReasonText(user)})`;
204+
}
205+
return "Not restricted";
206+
}
207+
188208
// Restriction dialog for editing restriction details
189209
function RestrictionDialog({
190210
user,
@@ -195,35 +215,31 @@ function RestrictionDialog({
195215
open: boolean,
196216
onOpenChange: (open: boolean) => void,
197217
}) {
198-
const restrictedByAdmin = (user as any).restrictedByAdmin ?? false;
199-
const restrictedByAdminReason = (user as any).restrictedByAdminReason ?? null;
200-
const restrictedByAdminPrivateDetails = (user as any).restrictedByAdminPrivateDetails ?? null;
201-
202-
const [publicReason, setPublicReason] = useState(restrictedByAdminReason ?? '');
203-
const [privateDetails, setPrivateDetails] = useState(restrictedByAdminPrivateDetails ?? '');
218+
const [publicReason, setPublicReason] = useState(user.restrictedByAdminReason ?? '');
219+
const [privateDetails, setPrivateDetails] = useState(user.restrictedByAdminPrivateDetails ?? '');
204220
const [isSaving, setIsSaving] = useState(false);
205221

206222
// Reset form when dialog opens
207223
const handleOpenChange = (newOpen: boolean) => {
208224
if (newOpen) {
209-
setPublicReason(restrictedByAdminReason ?? '');
210-
setPrivateDetails(restrictedByAdminPrivateDetails ?? '');
225+
setPublicReason(user.restrictedByAdminReason ?? '');
226+
setPrivateDetails(user.restrictedByAdminPrivateDetails ?? '');
211227
}
212228
onOpenChange(newOpen);
213229
};
214230

215231
const handleSaveAndRestrict = async () => {
216-
if (!privateDetails.trim()) {
217-
alert('Please enter the private details for the restriction.');
218-
return;
219-
}
232+
const trimmedPublicReason = publicReason.trim();
233+
const trimmedPrivateDetails = privateDetails.trim();
220234

221235
setIsSaving(true);
222236
try {
223-
await user.update({ restrictedByAdmin: true, restrictedByAdminReason: publicReason.trim() || null, restrictedByAdminPrivateDetails: privateDetails.trim() || null } as any);
237+
await user.update({
238+
restrictedByAdmin: true,
239+
restrictedByAdminReason: trimmedPublicReason.length > 0 ? trimmedPublicReason : null,
240+
restrictedByAdminPrivateDetails: trimmedPrivateDetails.length > 0 ? trimmedPrivateDetails : null,
241+
});
224242
onOpenChange(false);
225-
} catch (error) {
226-
captureError(`user-restriction-save-and-restrict-error`, new StackAssertionError(`Failed to save and restrict user ${user.id}`, { cause: error }));
227243
} finally {
228244
setIsSaving(false);
229245
}
@@ -236,7 +252,7 @@ function RestrictionDialog({
236252
restrictedByAdmin: false,
237253
restrictedByAdminReason: null,
238254
restrictedByAdminPrivateDetails: null,
239-
} as any);
255+
});
240256
onOpenChange(false);
241257
} finally {
242258
setIsSaving(false);
@@ -249,7 +265,9 @@ function RestrictionDialog({
249265
<DialogHeader>
250266
<DialogTitle>User Restriction</DialogTitle>
251267
<DialogDescription>
252-
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).
268+
{user.restrictedByAdmin
269+
? "This user is manually restricted. You can update the notes or remove the manual restriction."
270+
: "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."}
253271
</DialogDescription>
254272
</DialogHeader>
255273
<div className="flex flex-col gap-4 py-4">
@@ -263,21 +281,20 @@ function RestrictionDialog({
263281
/>
264282
</div>
265283
<div className="flex flex-col gap-2">
266-
<label className="text-sm font-medium">Private details (internal only)</label>
284+
<label className="text-sm font-medium">Private details (internal only, optional)</label>
267285
<Textarea
268286
value={privateDetails}
269287
onChange={(e) => setPrivateDetails(e.target.value)}
270288
placeholder="Internal notes, e.g., which sign-up rule triggered"
271-
required
272289
className="min-h-[80px]"
273290
disabled={isSaving}
274291
/>
275292
</div>
276293
</div>
277294
<DialogFooter className="flex-col sm:flex-row gap-2">
278-
{restrictedByAdmin && (
295+
{user.restrictedByAdmin && (
279296
<Button
280-
variant="destructive"
297+
variant="outline"
281298
onClick={handleRemoveRestriction}
282299
disabled={isSaving}
283300
className="sm:mr-auto"
@@ -293,7 +310,6 @@ function RestrictionDialog({
293310
Cancel
294311
</Button>
295312
<Button
296-
297313
onClick={handleSaveAndRestrict}
298314
disabled={isSaving}
299315
>
@@ -307,44 +323,54 @@ function RestrictionDialog({
307323

308324
// Restriction banner shown at top of page when user is restricted
309325
function RestrictionBanner({ user }: { user: ServerUser }) {
326+
const [restrictionDialogOpen, setRestrictionDialogOpen] = useState(false);
327+
310328
if (!user.isRestricted) return null;
311329

312-
const restrictedByAdmin = (user as any).restrictedByAdmin ?? false;
313-
const restrictedByAdminReason = (user as any).restrictedByAdminReason ?? null;
314-
const restrictedByAdminPrivateDetails = (user as any).restrictedByAdminPrivateDetails ?? null;
315330
const reasonText = getRestrictionReasonText(user);
316331

317332
return (
318-
<Alert variant="destructive" className="mb-4">
319-
<ProhibitIcon size={16} />
320-
<AlertTitle>This user is currently restricted</AlertTitle>
321-
<AlertDescription className="mt-2">
322-
<p className="mb-2">
323-
Restricted users cannot access your app by default. This user is restricted because: <strong>{reasonText}</strong>.
324-
</p>
325-
{user.restrictedReason?.type === 'email_not_verified' && (
326-
<p className="text-sm opacity-80">
327-
The user needs to verify their email address to remove this restriction.
328-
</p>
329-
)}
330-
{user.restrictedReason?.type === 'anonymous' && (
331-
<p className="text-sm opacity-80">
332-
Anonymous users must sign up with credentials to remove this restriction.
333+
<>
334+
<Alert variant="destructive" className="mb-4">
335+
<ProhibitIcon size={16} />
336+
<AlertTitle>This user is currently restricted</AlertTitle>
337+
<AlertDescription className="mt-2">
338+
<p className="mb-2">
339+
Restricted users cannot access your app by default. This user is restricted because: <strong>{reasonText}</strong>.
333340
</p>
334-
)}
335-
{user.restrictedReason?.type === 'restricted_by_administrator' && (
336-
<div className="text-sm opacity-80">
337-
<p>This user was manually restricted by an administrator.</p>
338-
{restrictedByAdminReason && (
339-
<p className="mt-1"><strong>Public reason:</strong> {restrictedByAdminReason}</p>
340-
)}
341-
{restrictedByAdminPrivateDetails && (
342-
<p className="mt-1"><strong>Private details:</strong> {restrictedByAdminPrivateDetails}</p>
343-
)}
344-
</div>
345-
)}
346-
</AlertDescription>
347-
</Alert>
341+
{user.restrictedReason?.type === 'email_not_verified' && (
342+
<p className="text-sm opacity-80">
343+
The user needs to verify their email address to remove this restriction.
344+
</p>
345+
)}
346+
{user.restrictedReason?.type === 'anonymous' && (
347+
<p className="text-sm opacity-80">
348+
Anonymous users must sign up with credentials to remove this restriction.
349+
</p>
350+
)}
351+
{user.restrictedReason?.type === 'restricted_by_administrator' && (
352+
<div className="text-sm opacity-80">
353+
<p>This user was manually restricted by an administrator.</p>
354+
{user.restrictedByAdminReason && (
355+
<p className="mt-1"><strong>Public reason:</strong> {user.restrictedByAdminReason}</p>
356+
)}
357+
{user.restrictedByAdminPrivateDetails && (
358+
<p className="mt-1"><strong>Private details:</strong> {user.restrictedByAdminPrivateDetails}</p>
359+
)}
360+
</div>
361+
)}
362+
<Button
363+
variant="outline"
364+
size="sm"
365+
onClick={() => setRestrictionDialogOpen(true)}
366+
className="mt-3"
367+
>
368+
{getRestrictionActionLabel(user)}
369+
</Button>
370+
</AlertDescription>
371+
</Alert>
372+
<RestrictionDialog user={user} open={restrictionDialogOpen} onOpenChange={setRestrictionDialogOpen} />
373+
</>
348374
);
349375
}
350376

@@ -413,7 +439,21 @@ function UserDetails({ user }: { user: ServerUser }) {
413439
}
414440

415441
function FraudSection({ user }: { user: ServerUser }) {
442+
const [restrictionDialogOpen, setRestrictionDialogOpen] = useState(false);
416443
const items = useMemo<DesignEditableGridItem[]>(() => [
444+
{
445+
type: "custom",
446+
icon: <ProhibitIcon size={14} />,
447+
name: "Manual restriction",
448+
children: (
449+
<span className={cn(
450+
"text-sm",
451+
user.restrictedByAdmin ? "font-medium text-destructive" : "text-muted-foreground",
452+
)}>
453+
{getManualRestrictionStatusText(user)}
454+
</span>
455+
),
456+
},
417457
{
418458
type: "text",
419459
icon: <ShieldIcon size={14} />,
@@ -463,15 +503,25 @@ function FraudSection({ user }: { user: ServerUser }) {
463503

464504
return (
465505
<section className="flex flex-col gap-3">
466-
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
467-
Fraud
468-
</h2>
506+
<div className="flex items-center justify-between gap-3">
507+
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
508+
Fraud
509+
</h2>
510+
<Button
511+
variant={user.restrictedByAdmin ? "outline" : "destructive"}
512+
size="sm"
513+
onClick={() => setRestrictionDialogOpen(true)}
514+
>
515+
{getRestrictionActionLabel(user)}
516+
</Button>
517+
</div>
469518
<DesignEditableGrid
470519
items={items}
471520
columns={2}
472521
size="sm"
473522
deferredSave={false}
474523
/>
524+
<RestrictionDialog user={user} open={restrictionDialogOpen} onOpenChange={setRestrictionDialogOpen} />
475525
</section>
476526
);
477527
}
@@ -1350,7 +1400,7 @@ const ACTIVITY_WEEKDAY_LABELS = [
13501400
] as const;
13511401

13521402
// Activity heatmap color ramp. Indexed by 0 = no activity, 1..4 = increasing
1353-
// intensity (buckets based on the user's own max activity over the window).
1403+
// log-scaled intensity based on the user's own max activity over the window.
13541404
// Tailwind needs the exact class strings at build time, so we keep them
13551405
// enumerated here rather than building them dynamically.
13561406
//
@@ -1369,7 +1419,7 @@ const ACTIVITY_COLORS = [
13691419

13701420
function activityLevel(activity: number, max: number): 0 | 1 | 2 | 3 | 4 {
13711421
if (activity <= 0 || max <= 0) return 0;
1372-
const intensity = activity / max;
1422+
const intensity = Math.log1p(activity) / Math.log1p(max);
13731423
if (intensity <= 0.25) return 1;
13741424
if (intensity <= 0.5) return 2;
13751425
if (intensity <= 0.75) return 3;

0 commit comments

Comments
 (0)