Skip to content

Commit 5cee3fa

Browse files
committed
feat: add sign out all devices
1 parent 3601ed9 commit 5cee3fa

8 files changed

Lines changed: 3524 additions & 114 deletions

File tree

apps/web/app/(org)/dashboard/settings/account/Settings.tsx

Lines changed: 196 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,27 @@ import {
55
Card,
66
CardDescription,
77
CardTitle,
8+
Dialog,
9+
DialogContent,
10+
DialogFooter,
11+
DialogHeader,
12+
DialogTitle,
813
Input,
914
Select,
1015
} from "@cap/ui";
1116
import { type ImageUpload, Organisation } from "@cap/web-domain";
1217
import { useMutation } from "@tanstack/react-query";
1318
import { Effect, Option } from "effect";
19+
import { LogOut } from "lucide-react";
1420
import { useRouter } from "next/navigation";
21+
import { signOut } from "next-auth/react";
1522
import { useEffect, useId, useState } from "react";
1623
import { toast } from "sonner";
1724
import { SignedImageUrl } from "@/components/SignedImageUrl";
1825
import { useEffectMutation, useRpcClient } from "@/lib/EffectRuntime";
1926
import { useDashboardContext } from "../../Contexts";
2027
import { ProfileImage } from "./components/ProfileImage";
21-
import { patchAccountSettings } from "./server";
28+
import { patchAccountSettings, signOutAllDevices } from "./server";
2229

2330
export const Settings = () => {
2431
const router = useRouter();
@@ -28,6 +35,7 @@ export const Settings = () => {
2835
const [defaultOrgId, setDefaultOrgId] = useState<
2936
Organisation.OrganisationId | undefined
3037
>(user?.defaultOrgId || undefined);
38+
const [signOutAllDevicesOpen, setSignOutAllDevicesOpen] = useState(false);
3139
const firstNameId = useId();
3240
const lastNameId = useId();
3341
const contactEmailId = useId();
@@ -72,6 +80,18 @@ export const Settings = () => {
7280
},
7381
});
7482

83+
const signOutAllDevicesMutation = useMutation({
84+
mutationFn: signOutAllDevices,
85+
onSuccess: () => {
86+
toast.success("Signed out of all devices");
87+
setSignOutAllDevicesOpen(false);
88+
signOut({ callbackUrl: "/login" });
89+
},
90+
onError: () => {
91+
toast.error("Failed to sign out of all devices");
92+
},
93+
});
94+
7595
// Prevent navigation when there are unsaved changes
7696
useEffect(() => {
7797
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
@@ -153,117 +173,184 @@ export const Settings = () => {
153173
};
154174

155175
return (
156-
<form
157-
onSubmit={(e) => {
158-
e.preventDefault();
159-
updateName();
160-
}}
161-
>
162-
<div className="grid gap-6 w-full md:grid-cols-2">
163-
<Card className="space-y-4">
164-
<div className="space-y-1">
165-
<CardTitle>Profile image</CardTitle>
166-
<CardDescription>
167-
This image appears in your profile, comments, and shared caps.
168-
</CardDescription>
169-
</div>
170-
<ProfileImage
171-
initialPreviewUrl={profileImagePreviewUrl}
172-
onChange={handleProfileImageChange}
173-
onRemove={handleProfileImageRemove}
174-
disabled={isProfileImageMutating}
175-
isUploading={uploadProfileImageMutation.isPending}
176-
isRemoving={removeProfileImageMutation.isPending}
177-
userName={user?.name}
178-
/>
179-
</Card>
180-
<Card className="space-y-4">
181-
<div className="space-y-1">
182-
<CardTitle>Your name</CardTitle>
183-
<CardDescription>
184-
Changing your name below will update how your name appears when
185-
sharing a Cap, and in your profile.
186-
</CardDescription>
187-
</div>
188-
<div className="flex flex-col flex-wrap gap-3 w-full">
189-
<div className="flex-1">
190-
<Input
191-
type="text"
192-
placeholder="First name"
193-
onChange={(e) => setFirstName(e.target.value)}
194-
defaultValue={firstName as string}
195-
id={firstNameId}
196-
name="firstName"
197-
/>
176+
<>
177+
<form
178+
onSubmit={(e) => {
179+
e.preventDefault();
180+
updateName();
181+
}}
182+
>
183+
<div className="grid gap-6 w-full md:grid-cols-2">
184+
<Card className="space-y-4">
185+
<div className="space-y-1">
186+
<CardTitle>Profile image</CardTitle>
187+
<CardDescription>
188+
This image appears in your profile, comments, and shared caps.
189+
</CardDescription>
198190
</div>
199-
<div className="flex-1 space-y-2">
200-
<Input
201-
type="text"
202-
placeholder="Last name"
203-
onChange={(e) => setLastName(e.target.value)}
204-
defaultValue={lastName as string}
205-
id={lastNameId}
206-
name="lastName"
207-
/>
191+
<ProfileImage
192+
initialPreviewUrl={profileImagePreviewUrl}
193+
onChange={handleProfileImageChange}
194+
onRemove={handleProfileImageRemove}
195+
disabled={isProfileImageMutating}
196+
isUploading={uploadProfileImageMutation.isPending}
197+
isRemoving={removeProfileImageMutation.isPending}
198+
userName={user?.name}
199+
/>
200+
</Card>
201+
<Card className="space-y-4">
202+
<div className="space-y-1">
203+
<CardTitle>Your name</CardTitle>
204+
<CardDescription>
205+
Changing your name below will update how your name appears when
206+
sharing a Cap, and in your profile.
207+
</CardDescription>
208208
</div>
209-
</div>
210-
</Card>
211-
<Card className="flex flex-col gap-4">
212-
<div className="space-y-1">
213-
<CardTitle>Contact email address</CardTitle>
214-
<CardDescription>
215-
This is the email address you used to sign up to Cap with.
216-
</CardDescription>
217-
</div>
218-
<Input
219-
type="email"
220-
value={user?.email as string}
221-
id={contactEmailId}
222-
name="contactEmail"
223-
disabled
224-
/>
225-
</Card>
226-
<Card className="flex flex-col gap-4">
227-
<div className="space-y-1">
228-
<CardTitle>Default organization</CardTitle>
229-
<CardDescription>This is the default organization</CardDescription>
230-
</div>
231-
232-
<Select
233-
placeholder="Default organization"
234-
value={
235-
defaultOrgId ??
236-
user?.defaultOrgId ??
237-
organizationData?.[0]?.organization.id ??
238-
""
239-
}
240-
onValueChange={(value) =>
241-
setDefaultOrgId(Organisation.OrganisationId.make(value))
242-
}
243-
options={(organizationData || []).map((org) => ({
244-
value: org.organization.id,
245-
label: org.organization.name,
246-
image: (
247-
<SignedImageUrl
248-
className="size-5"
249-
image={org.organization.iconUrl}
250-
name={org.organization.name}
209+
<div className="flex flex-col flex-wrap gap-3 w-full">
210+
<div className="flex-1">
211+
<Input
212+
type="text"
213+
placeholder="First name"
214+
onChange={(e) => setFirstName(e.target.value)}
215+
defaultValue={firstName as string}
216+
id={firstNameId}
217+
name="firstName"
218+
/>
219+
</div>
220+
<div className="flex-1 space-y-2">
221+
<Input
222+
type="text"
223+
placeholder="Last name"
224+
onChange={(e) => setLastName(e.target.value)}
225+
defaultValue={lastName as string}
226+
id={lastNameId}
227+
name="lastName"
251228
/>
252-
),
253-
}))}
254-
/>
255-
</Card>
256-
</div>
257-
<Button
258-
disabled={!firstName || updateNamePending || !hasChanges}
259-
className="mt-6"
260-
type="submit"
261-
size="sm"
262-
variant="dark"
263-
spinner={updateNamePending}
229+
</div>
230+
</div>
231+
</Card>
232+
<Card className="flex flex-col gap-4">
233+
<div className="space-y-1">
234+
<CardTitle>Contact email address</CardTitle>
235+
<CardDescription>
236+
This is the email address you used to sign up to Cap with.
237+
</CardDescription>
238+
</div>
239+
<Input
240+
type="email"
241+
value={user?.email as string}
242+
id={contactEmailId}
243+
name="contactEmail"
244+
disabled
245+
/>
246+
</Card>
247+
<Card className="flex flex-col gap-4">
248+
<div className="space-y-1">
249+
<CardTitle>Default organization</CardTitle>
250+
<CardDescription>
251+
This is the default organization
252+
</CardDescription>
253+
</div>
254+
255+
<Select
256+
placeholder="Default organization"
257+
value={
258+
defaultOrgId ??
259+
user?.defaultOrgId ??
260+
organizationData?.[0]?.organization.id ??
261+
""
262+
}
263+
onValueChange={(value) =>
264+
setDefaultOrgId(Organisation.OrganisationId.make(value))
265+
}
266+
options={(organizationData || []).map((org) => ({
267+
value: org.organization.id,
268+
label: org.organization.name,
269+
image: (
270+
<SignedImageUrl
271+
className="size-5"
272+
image={org.organization.iconUrl}
273+
name={org.organization.name}
274+
/>
275+
),
276+
}))}
277+
/>
278+
</Card>
279+
</div>
280+
<Button
281+
disabled={!firstName || updateNamePending || !hasChanges}
282+
className="mt-6"
283+
type="submit"
284+
size="sm"
285+
variant="dark"
286+
spinner={updateNamePending}
287+
>
288+
{updateNamePending ? "Saving..." : "Save"}
289+
</Button>
290+
</form>
291+
<Card className="flex flex-col gap-4 mt-6 md:flex-row md:items-center md:justify-between">
292+
<div className="space-y-1">
293+
<CardTitle>Sign out of all devices</CardTitle>
294+
<CardDescription>
295+
Invalidate every Cap web session and desktop app authentication
296+
token connected to your account.
297+
</CardDescription>
298+
</div>
299+
<Button
300+
type="button"
301+
size="sm"
302+
variant="destructive"
303+
icon={<LogOut className="size-4" />}
304+
onClick={() => setSignOutAllDevicesOpen(true)}
305+
>
306+
Sign out all devices
307+
</Button>
308+
</Card>
309+
<Dialog
310+
open={signOutAllDevicesOpen}
311+
onOpenChange={setSignOutAllDevicesOpen}
264312
>
265-
{updateNamePending ? "Saving..." : "Save"}
266-
</Button>
267-
</form>
313+
<DialogContent>
314+
<DialogHeader
315+
icon={<LogOut className="size-4" />}
316+
description="This will immediately invalidate existing Cap web sessions, desktop session tokens, and desktop API keys for your account."
317+
>
318+
<DialogTitle>Sign out of all devices?</DialogTitle>
319+
</DialogHeader>
320+
<div className="p-5 space-y-3 text-sm text-gray-11">
321+
<p>
322+
You will be signed out of this browser after the reset completes.
323+
</p>
324+
<p>
325+
The Cap desktop app may need you to click Sign out, then sign in
326+
again before uploads and settings sync work.
327+
</p>
328+
</div>
329+
<DialogFooter>
330+
<Button
331+
type="button"
332+
size="sm"
333+
variant="gray"
334+
onClick={() => setSignOutAllDevicesOpen(false)}
335+
>
336+
Cancel
337+
</Button>
338+
<Button
339+
type="button"
340+
size="sm"
341+
variant="destructive"
342+
icon={<LogOut className="size-4" />}
343+
onClick={() => signOutAllDevicesMutation.mutate()}
344+
spinner={signOutAllDevicesMutation.isPending}
345+
disabled={signOutAllDevicesMutation.isPending}
346+
>
347+
{signOutAllDevicesMutation.isPending
348+
? "Signing out..."
349+
: "Sign out all devices"}
350+
</Button>
351+
</DialogFooter>
352+
</DialogContent>
353+
</Dialog>
354+
</>
268355
);
269356
};

apps/web/app/(org)/dashboard/settings/account/server.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import { db } from "@cap/database";
44
import { getCurrentUser } from "@cap/database/auth/session";
55
import {
6+
authApiKeys,
67
organizationMembers,
78
organizations,
9+
sessions,
810
users,
911
} from "@cap/database/schema";
1012
import type { Organisation } from "@cap/web-domain";
11-
import { eq, or } from "drizzle-orm";
13+
import { eq, or, sql } from "drizzle-orm";
1214
import { revalidatePath } from "next/cache";
1315

1416
export async function patchAccountSettings(
@@ -65,3 +67,17 @@ export async function patchAccountSettings(
6567

6668
revalidatePath("/dashboard/settings/account");
6769
}
70+
71+
export async function signOutAllDevices() {
72+
const currentUser = await getCurrentUser();
73+
if (!currentUser) throw new Error("Unauthorized");
74+
75+
await db().transaction(async (tx) => {
76+
await tx
77+
.update(users)
78+
.set({ authSessionVersion: sql`${users.authSessionVersion} + 1` })
79+
.where(eq(users.id, currentUser.id));
80+
await tx.delete(sessions).where(eq(sessions.userId, currentUser.id));
81+
await tx.delete(authApiKeys).where(eq(authApiKeys.userId, currentUser.id));
82+
});
83+
}

0 commit comments

Comments
 (0)