Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/services/db/db.types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions src/services/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { redirect } from 'next/navigation';
import { cache } from 'react';
import { UserSession } from 'schema/users';
import { getServerContext } from 'services/server_context';
import { UserRole, getUser } from 'services/users/users_repo';
import { RoutePath, routeFor } from 'utils/routes';

export const getUserSession = cache(async (): Promise<UserSession | undefined> => {
Expand All @@ -28,6 +29,35 @@ export const verifyHasSession = cache(async () => {
return session;
});

// Looks up the current user's site-level role. Returns undefined if logged out or the user
// record can't be found. Kept off the getUserSession hot path - only call when a role check
// is actually needed (e.g. owner-gated admin routes).
export const getUserRole = cache(async (): Promise<UserRole | undefined> => {
const session = await getUserSession();
if (!session) {
return undefined;
}
const result = await getUser({ by: 'id', id: session.id });
if (!result.success) {
return undefined;
}
return result.value.role as UserRole;
});

export const verifyOwner = cache(async () => {
const session = await verifyHasSession();
const role = await getUserRole();
// The session is already confirmed, so a missing role means the lookup failed - fail loudly
// rather than silently denying access to a legitimate owner.
if (role == null) {
throw new Error('Failed to load user role');
}
if (role !== UserRole.OWNER) {
redirect(routeFor([RoutePath.MAP_LIST]));
}
return session;
});

export async function clearUserSession() {
const { supabase } = await getServerContext();
await supabase.auth.signOut({ scope: 'local' });
Expand Down
59 changes: 59 additions & 0 deletions src/services/session/tests/roles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { _unwrap } from 'base/result';
import { testUser } from 'services/jest_helpers';
import { getServerContext } from 'services/server_context';
import { getUserRole, verifyOwner } from 'services/session/session';
import { _setCurrentUserForTesting } from 'services/session/supabase_fake';
import { UserRole, createUser, getUser } from 'services/users/users_repo';
import * as db from 'zapatos/db';

const signUpTestUser = () =>
_unwrap(
createUser({
email: testUser.email,
username: testUser.username,
password: testUser.password,
})
);

const promoteToOwner = async (id: string) => {
const { pool } = await getServerContext();
await db.update('users', { role: UserRole.OWNER }, { id }).run(pool);
};

describe('user roles', () => {
it('defaults new users to the user role', async () => {
await signUpTestUser();
const user = await _unwrap(getUser({ by: 'username', username: testUser.username }));
expect(user.role).toEqual(UserRole.USER);
});

it('getUserRole reflects the stored role', async () => {
const created = await signUpTestUser();
_setCurrentUserForTesting({
id: created.id,
email: testUser.email,
username: testUser.username,
});

expect(await getUserRole()).toEqual(UserRole.USER);

await promoteToOwner(created.id);
expect(await getUserRole()).toEqual(UserRole.OWNER);
});

it('verifyOwner allows owners and redirects everyone else', async () => {
const created = await signUpTestUser();
_setCurrentUserForTesting({
id: created.id,
email: testUser.email,
username: testUser.username,
});

// A normal user is redirected away, which surfaces as a thrown Next redirect.
await expect(verifyOwner()).rejects.toThrow();

await promoteToOwner(created.id);
const session = await verifyOwner();
expect(session.id).toEqual(created.id);
});
});
5 changes: 5 additions & 0 deletions src/services/users/users_repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const enum EmailStatus {
UNVERIFIED = 'U',
VERIFIED = 'V',
}
// Site-level roles. 'user' is the default; 'owner' is a superadmin.
export const enum UserRole {
USER = 'user',
OWNER = 'owner',
}

type GetUserOpts = GetUserByUsernameOpts | GetUserByIdOpts;
type GetUserByUsernameOpts = { by: 'username'; username: string };
Expand Down
2 changes: 1 addition & 1 deletion src/services/zapatos/custom/index.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 30 additions & 0 deletions src/services/zapatos/schema.d.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions supabase/migrations/20260608123614_users_add_role.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
alter table "public"."users" add column "role" text not null default 'user'::text;

alter table "public"."users" add constraint "users_role_check" CHECK ((role = ANY (ARRAY['user'::text, 'owner'::text]))) not valid;

alter table "public"."users" validate constraint "users_role_check";


4 changes: 3 additions & 1 deletion supabase/schemas/users.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ CREATE TABLE users (
username varchar(32) unique not null,
-- Keep email_status around for the pre-Supabase users - we need to confirm their emails still
email_status char not null,
supabase_id uuid unique not null
supabase_id uuid unique not null,
-- Site-level role: 'user' (default) or 'owner' (superadmin)
role text not null default 'user' check (role in ('user', 'owner'))
);

CREATE INDEX idx_users_supabase_id ON users (supabase_id);
Expand Down
Loading