@@ -46,7 +46,7 @@ import { KnownErrors } from "@stackframe/stack-shared";
4646import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config" ;
4747import { normalizeCountryCode } from "@stackframe/stack-shared/dist/schema-fields" ;
4848import { 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' ;
5050import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises" ;
5151import { deindent } from "@stackframe/stack-shared/dist/utils/strings" ;
5252import { 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
189209function 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
309325function 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
415441function 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
13701420function 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