feat: support skipping onboarding

This commit is contained in:
Aarnav Tale 2025-04-02 20:34:32 -04:00
parent 7b1340c93e
commit d5fb8a2966
5 changed files with 73 additions and 15 deletions

View File

@ -63,6 +63,11 @@ export async function loader({
return false; return false;
} }
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject; return subject === sessionUser.subject;
}); });
@ -102,7 +107,8 @@ export default function Shell() {
return ( return (
<> <>
<Header {...data} /> <Header {...data} />
{data.uiAccess ? ( {/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? (
<Outlet /> <Outlet />
) : ( ) : (
<Card className="mx-auto w-fit mt-24"> <Card className="mx-auto w-fit mt-24">

View File

@ -15,6 +15,7 @@ export default [
// Double nested to separate error propagations // Double nested to separate error propagations
layout('layouts/shell.tsx', [ layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'), route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [ layout('layouts/dashboard.tsx', [
...prefix('/machines', [ ...prefix('/machines', [
index('routes/machines/overview.tsx'), index('routes/machines/overview.tsx'),

View File

@ -0,0 +1,16 @@
import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
return redirect('/login');
}
context.sessions.overrideOnboarding(user.subject, true);
return redirect('/machines');
}

View File

@ -1,3 +1,4 @@
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { GrApple } from 'react-icons/gr'; import { GrApple } from 'react-icons/gr';
import { ImFinder } from 'react-icons/im'; import { ImFinder } from 'react-icons/im';
@ -335,6 +336,12 @@ export default function Page() {
</div> </div>
)} )}
</Card> </Card>
<NavLink to="/onboarding/skip" className="col-span-2 w-max mx-auto">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
</Button>
</NavLink>
</div> </div>
</div> </div>
); );

View File

@ -47,12 +47,12 @@ interface CookieOptions {
class Sessionizer { class Sessionizer {
private storage: SessionStorage<JoinedSession, Error>; private storage: SessionStorage<JoinedSession, Error>;
private caps: Record<string, Capabilities>; private caps: Record<string, { c: Capabilities; oo?: boolean }>;
private capsPath?: string; private capsPath?: string;
constructor( constructor(
options: CookieOptions, options: CookieOptions,
caps: Record<string, Capabilities>, caps: Record<string, { c: Capabilities; oo?: boolean }>,
capsPath?: string, capsPath?: string,
) { ) {
this.caps = caps; this.caps = caps;
@ -86,7 +86,7 @@ class Sessionizer {
} }
roleForSubject(subject: string): keyof typeof Roles | undefined { roleForSubject(subject: string): keyof typeof Roles | undefined {
const role = this.caps[subject]; const role = this.caps[subject].c;
// We need this in string form based on Object.keys of the roles // We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) { for (const [key, value] of Object.entries(Roles)) {
if (value === role) { if (value === role) {
@ -95,6 +95,10 @@ class Sessionizer {
} }
} }
onboardForSubject(subject: string) {
return this.caps[subject].oo ?? false;
}
// Given an OR of capabilities, check if the session has the required // Given an OR of capabilities, check if the session has the required
// capabilities. If not, return false. Can throw since it calls auth() // capabilities. If not, return false. Can throw since it calls auth()
async check(request: Request, capabilities: Capabilities) { async check(request: Request, capabilities: Capabilities) {
@ -120,10 +124,10 @@ class Sessionizer {
const role = this.caps[subject]; const role = this.caps[subject];
if (!role) { if (!role) {
const memberRole = await this.registerSubject(subject); const memberRole = await this.registerSubject(subject);
return (capabilities & memberRole) === capabilities; return (capabilities & memberRole.c) === capabilities;
} }
return (capabilities & role) === capabilities; return (capabilities & role.c) === capabilities;
} }
async checkSubject(subject: string, capabilities: Capabilities) { async checkSubject(subject: string, capabilities: Capabilities) {
@ -143,10 +147,10 @@ class Sessionizer {
const role = this.caps[subject]; const role = this.caps[subject];
if (!role) { if (!role) {
const memberRole = await this.registerSubject(subject); const memberRole = await this.registerSubject(subject);
return (capabilities & memberRole) === capabilities; return (capabilities & memberRole.c) === capabilities;
} }
return (capabilities & role) === capabilities; return (capabilities & role.c) === capabilities;
} }
// This code is very simple, if the user does not exist in the database // This code is very simple, if the user does not exist in the database
@ -160,13 +164,13 @@ class Sessionizer {
if (Object.keys(this.caps).length === 0) { if (Object.keys(this.caps).length === 0) {
log.debug('auth', 'First user registered as owner: %s', subject); log.debug('auth', 'First user registered as owner: %s', subject);
this.caps[subject] = Roles.owner; this.caps[subject] = { c: Roles.owner };
await this.flushUserDatabase(); await this.flushUserDatabase();
return this.caps[subject]; return this.caps[subject];
} }
log.debug('auth', 'New user registered as member: %s', subject); log.debug('auth', 'New user registered as member: %s', subject);
this.caps[subject] = Roles.member; this.caps[subject] = { c: Roles.member };
await this.flushUserDatabase(); await this.flushUserDatabase();
return this.caps[subject]; return this.caps[subject];
} }
@ -176,7 +180,11 @@ class Sessionizer {
return; return;
} }
const data = Object.entries(this.caps).map(([u, c]) => ({ u, c })); const data = Object.entries(this.caps).map(([u, { c, oo }]) => ({
u,
c,
oo,
}));
try { try {
const handle = await open(this.capsPath, 'w'); const handle = await open(this.capsPath, 'w');
await handle.write(JSON.stringify(data)); await handle.write(JSON.stringify(data));
@ -193,11 +201,17 @@ class Sessionizer {
return false; return false;
} }
this.caps[subject] = Roles[role]; this.caps[subject].c = Roles[role];
await this.flushUserDatabase(); await this.flushUserDatabase();
return true; return true;
} }
// Overrides the onboarding status for a subject
async overrideOnboarding(subject: string, onboarding: boolean) {
this.caps[subject].oo = onboarding;
await this.flushUserDatabase();
}
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) { getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
return this.storage.getSession(request.headers.get('cookie')) as Promise< return this.storage.getSession(request.headers.get('cookie')) as Promise<
Session<T, Error> Session<T, Error>
@ -217,7 +231,13 @@ export async function createSessionStorage(
options: CookieOptions, options: CookieOptions,
usersPath?: string, usersPath?: string,
) { ) {
const map: Record<string, Capabilities> = {}; const map: Record<
string,
{
c: number;
oo?: boolean;
}
> = {};
if (usersPath) { if (usersPath) {
// We need to load our users from the file (default to empty map) // We need to load our users from the file (default to empty map)
// We then translate each user into a capability object using the helper // We then translate each user into a capability object using the helper
@ -226,7 +246,10 @@ export async function createSessionStorage(
log.debug('config', 'Loaded %d users from database', data.length); log.debug('config', 'Loaded %d users from database', data.length);
for (const user of data) { for (const user of data) {
map[user.u] = user.c; map[user.u] = {
c: user.c,
oo: user.oo,
};
} }
} }
@ -248,7 +271,11 @@ async function loadUserFile(path: string) {
try { try {
const data = await readFile(realPath, 'utf8'); const data = await readFile(realPath, 'utf8');
const users = JSON.parse(data.trim()) as { u?: string; c?: number }[]; const users = JSON.parse(data.trim()) as {
u?: string;
c?: number;
oo?: boolean;
}[];
// Never trust user input // Never trust user input
return users.filter( return users.filter(
@ -256,6 +283,7 @@ async function loadUserFile(path: string) {
) as { ) as {
u: string; u: string;
c: number; c: number;
oo?: boolean;
}[]; }[];
} catch (error) { } catch (error) {
log.debug('config', 'Error reading user database file: %s', error); log.debug('config', 'Error reading user database file: %s', error);