Skip to content

Commit b9beef6

Browse files
committed
feat: major progress on interacting with member attendance
1 parent f01e472 commit b9beef6

File tree

71 files changed

+3141
-741
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+3141
-741
lines changed

apps/api/adonisrc.ts

+32-31
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,25 @@ import { defineConfig } from '@adonisjs/core/app';
33
// biome-ignore lint/style/noDefaultExport: This must be a default export
44
export default defineConfig({
55
/*
6-
|--------------------------------------------------------------------------
7-
| Commands
8-
|--------------------------------------------------------------------------
9-
|
10-
| List of ace commands to register from packages. The application commands
11-
| will be scanned automatically from the "./commands" directory.
12-
|
13-
*/
6+
|--------------------------------------------------------------------------
7+
| Commands
8+
|--------------------------------------------------------------------------
9+
|
10+
| List of ace commands to register from packages. The application commands
11+
| will be scanned automatically from the "./commands" directory.
12+
|
13+
*/
1414
commands: [() => import('@adonisjs/core/commands'), () => import('@adonisjs/bouncer/commands')],
1515

1616
/*
17-
|--------------------------------------------------------------------------
18-
| Service providers
19-
|--------------------------------------------------------------------------
20-
|
21-
| List of service providers to import and register when booting the
22-
| application
23-
|
24-
*/
17+
|--------------------------------------------------------------------------
18+
| Service providers
19+
|--------------------------------------------------------------------------
20+
|
21+
| List of service providers to import and register when booting the
22+
| application
23+
|
24+
*/
2525
providers: [
2626
() => import('@adonisjs/core/providers/app_provider'),
2727
() => import('@adonisjs/core/providers/hash_provider'),
@@ -34,27 +34,28 @@ export default defineConfig({
3434
() => import('@adonisjs/redis/redis_provider'),
3535
() => import('@adonisjs/session/session_provider'),
3636
() => import('@adonisjs/bouncer/bouncer_provider'),
37+
() => import('@adonisjs/transmit/transmit_provider'),
3738
],
3839

3940
/*
40-
|--------------------------------------------------------------------------
41-
| Preloads
42-
|--------------------------------------------------------------------------
43-
|
44-
| List of modules to import before starting the application.
45-
|
46-
*/
41+
|--------------------------------------------------------------------------
42+
| Preloads
43+
|--------------------------------------------------------------------------
44+
|
45+
| List of modules to import before starting the application.
46+
|
47+
*/
4748
preloads: [() => import('#start/routes'), () => import('#start/kernel')],
4849

4950
/*
50-
|--------------------------------------------------------------------------
51-
| Tests
52-
|--------------------------------------------------------------------------
53-
|
54-
| List of test suites to organize tests by their type. Feel free to remove
55-
| and add additional suites.
56-
|
57-
*/
51+
|--------------------------------------------------------------------------
52+
| Tests
53+
|--------------------------------------------------------------------------
54+
|
55+
| List of test suites to organize tests by their type. Feel free to remove
56+
| and add additional suites.
57+
|
58+
*/
5859
tests: {
5960
suites: [],
6061
forceExit: false,

apps/api/app/auth/auth_service.ts

+2
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export class AuthService {
8787
})
8888
.returning({ id: Schema.users.id });
8989

90+
assert(user);
91+
9092
await tx.insert(Schema.credentials).values({
9193
userId: user.id,
9294
deviceType: verification.registrationInfo.credentialDeviceType,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { inject } from '@adonisjs/core';
2+
import type * as Schema from '#database/schema';
3+
import type { BouncerUser } from '#middleware/initialize_bouncer_middleware';
4+
import { injectHelper } from '../../util/inject_helper.js';
5+
import { GuestPasswordService } from '../guest_password/guest_password_service.js';
6+
import type { TeamSchema } from '../team/schemas/team_schema.js';
7+
import type { TeamMemberSchema } from '../team_member/schemas/team_member_schema.js';
8+
import { TeamMemberService } from '../team_member/team_member_service.js';
9+
import { TeamUserService } from '../team_user/team_user_service.js';
10+
11+
export type TeamRole = Schema.TeamUserRole | 'guestToken';
12+
13+
@inject()
14+
@injectHelper(GuestPasswordService, TeamUserService, TeamMemberService)
15+
export class AuthorizationService {
16+
constructor(
17+
private readonly guestPasswordService: GuestPasswordService,
18+
private readonly teamUserService: TeamUserService,
19+
private readonly teamMemberService: TeamMemberService,
20+
) {}
21+
22+
async hasRoles(actor: BouncerUser, team: Pick<TeamSchema, 'slug'>, roles: TeamRole[]): Promise<boolean> {
23+
if (actor.id === undefined) {
24+
// No team user, so we can only check the guest token
25+
26+
if (!roles.includes('guestToken')) {
27+
// Guest token wasn't a valid role, so we don't need to check the token
28+
29+
return false;
30+
}
31+
32+
// Check if the token is still valid
33+
return this.guestPasswordService.teamHasGuestToken(team, actor.unvalidatedGuestToken);
34+
}
35+
36+
// Check if the DB contains any of the allowed roles
37+
return this.teamUserService.userHasRoleInTeam(
38+
actor,
39+
team,
40+
roles.filter((role): role is Schema.TeamUserRole => role !== 'guestToken'),
41+
);
42+
}
43+
44+
/** Check whether an actor has a role by querying via team member. */
45+
async hasRolesByTeamMember(
46+
actor: BouncerUser,
47+
teamMember: Pick<TeamMemberSchema, 'id'>,
48+
roles: TeamRole[],
49+
): Promise<boolean> {
50+
// TODO: Rewrite this to do one query instead of two - I'm not optimizing for performance for the MVP
51+
52+
const team = await this.teamMemberService.getTeamByMember(teamMember);
53+
54+
if (!team) {
55+
// This should only ever be falsy if the team member ID doesn't exist anymore, and thus isn't associated with a team
56+
return false;
57+
}
58+
59+
return this.hasRoles(actor, team, roles);
60+
}
61+
}

apps/api/app/db/db_service.ts

+2
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,5 @@ const connection = postgres(postgresUrl.release());
88
export const db = drizzle(connection, {
99
schema: { ...schema, ...relations },
1010
});
11+
12+
export type Db = typeof db;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import assert from 'node:assert/strict';
2+
import type { HttpContext } from '@adonisjs/core/http';
3+
import redis from '@adonisjs/redis/services/main';
4+
import cuid2 from '@paralleldrive/cuid2';
5+
import { TRPCError } from '@trpc/server';
6+
import { convert } from 'convert';
7+
import { and, count, eq } from 'drizzle-orm';
8+
import * as Schema from '#database/schema';
9+
import { db } from '../db/db_service.js';
10+
import type { TeamSchema } from '../team/schemas/team_schema.js';
11+
12+
/** Manages tokens for guest passwords, which allow limited access to a team. */
13+
export class GuestPasswordService {
14+
private static redisKey(teamSlug: string): string {
15+
return `team:${teamSlug}:guestTokens`;
16+
}
17+
18+
private static readonly GUEST_PASSWORD_SESSION_LIFETIME = convert(6, 'months');
19+
20+
private async verifyPassword(
21+
password: Pick<TeamSchema, 'password'>,
22+
team: Pick<TeamSchema, 'slug'>,
23+
): Promise<boolean> {
24+
const [result] = await db
25+
.select({ count: count() })
26+
.from(Schema.teams)
27+
.where(and(eq(Schema.teams.slug, team.slug), eq(Schema.teams.password, password.password)));
28+
29+
assert(result);
30+
31+
return result.count > 0;
32+
}
33+
34+
/** Do a guest password login for a team. */
35+
async guestPasswordLogin(input: Pick<TeamSchema, 'password' | 'slug'>, context: HttpContext): Promise<void> {
36+
const correct = await this.verifyPassword(input, input);
37+
38+
if (!correct) {
39+
throw new TRPCError({
40+
code: 'UNAUTHORIZED',
41+
message: 'Incorrect password',
42+
});
43+
}
44+
45+
const token = cuid2.createId();
46+
47+
const pipeline = redis.pipeline();
48+
pipeline.sadd(GuestPasswordService.redisKey(input.slug), token);
49+
pipeline.expire(
50+
GuestPasswordService.redisKey(input.slug),
51+
GuestPasswordService.GUEST_PASSWORD_SESSION_LIFETIME.to('s'),
52+
);
53+
await pipeline.exec();
54+
55+
context.session.put('guestToken', token);
56+
}
57+
58+
async teamHasGuestToken(team: Pick<TeamSchema, 'slug'>, token: string): Promise<boolean> {
59+
const result = await redis.sismember(GuestPasswordService.redisKey(team.slug), token);
60+
61+
return result === 1;
62+
}
63+
64+
async clearTokensForTeam(team: Pick<TeamSchema, 'slug'>): Promise<void> {
65+
await redis.del(GuestPasswordService.redisKey(team.slug));
66+
}
67+
}

apps/api/app/middleware/initialize_bouncer_middleware.ts

+18-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import type { HttpContext } from '@adonisjs/core/http';
66
import type { NextFn } from '@adonisjs/core/types/http';
77
import type { UserSchema } from '../user/schemas/user_schema.js';
88

9-
export type BouncerUser = Pick<UserSchema, 'id'>;
9+
export type BouncerUser =
10+
// Regular team user auth
11+
| Pick<UserSchema, 'id'>
12+
// Guest password auth
13+
| {
14+
id: undefined;
15+
unvalidatedGuestToken: string;
16+
};
1017

1118
export type AppBouncer = Bouncer<BouncerUser, typeof abilities, typeof policies>;
1219

@@ -24,8 +31,17 @@ export default class InitializeBouncerMiddleware {
2431
ctx.bouncer = new Bouncer(
2532
(): BouncerUser | undefined => {
2633
const userId = ctx.session.get('userId') as string | undefined;
34+
const guestToken = ctx.session.get('guestToken') as string | undefined;
2735

28-
return userId ? { id: userId } : undefined;
36+
if (userId) {
37+
return { id: userId };
38+
}
39+
40+
if (guestToken) {
41+
return { id: undefined, unvalidatedGuestToken: guestToken };
42+
}
43+
44+
return undefined;
2945
},
3046
abilities,
3147
policies,

apps/api/app/policies/main.ts

+4
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
*/
1414

1515
export const policies = {
16+
// biome-ignore lint/style/useNamingConvention: Convention is to use PascalCase
17+
TeamMemberPolicy: () => import('../team_member/team_member_policy.js'),
1618
// biome-ignore lint/style/useNamingConvention: Convention is to use PascalCase
1719
UserPolicy: () => import('../user/user_policy.js'),
20+
// biome-ignore lint/style/useNamingConvention: Convention is to use PascalCase
21+
TeamPolicy: () => import('../team/team_policy.js'),
1822
};

apps/api/app/auth/auth_router.ts apps/api/app/routers/accounts_router.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { inject } from '@adonisjs/core';
22
import { z } from 'zod';
33
import { injectHelper } from '../../util/inject_helper.js';
4-
import { authedProcedure, publicProcedure, router } from '../trpc/trpc_service.js';
4+
import { AuthService } from '../auth/auth_service.js';
5+
import { publicProcedure, router } from '../trpc/trpc_service.js';
56
import { UserSchema } from '../user/schemas/user_schema.js';
6-
import { AuthService } from './auth_service.js';
77

88
@inject()
99
@injectHelper(AuthService)
10-
export class AuthRouter {
10+
export class AccountsRouter {
1111
constructor(private readonly authService: AuthService) {}
1212

1313
getRouter() {
@@ -54,7 +54,7 @@ export class AuthRouter {
5454
return this.authService.verifyLogin(input.body, ctx.context);
5555
}),
5656
}),
57-
logOut: authedProcedure.mutation(({ ctx }) => {
57+
logOut: publicProcedure.mutation(({ ctx }) => {
5858
ctx.context.session.clear();
5959
}),
6060
});

apps/api/app/routers/app_router.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
import { inject } from '@adonisjs/core';
22
import { injectHelper } from '../../util/inject_helper.js';
3-
import { AuthRouter } from '../auth/auth_router.js';
4-
import { TeamRouter } from '../team/team_router.js';
53
import { router } from '../trpc/trpc_service.js';
6-
import { UserRouter } from '../user/user_router.js';
4+
import { AccountsRouter } from './accounts_router.js';
5+
import { GuestRouter } from './guest_router.js';
6+
import { TeamRouter } from './team_router.js';
7+
import { UserRouter } from './user_router.js';
78

89
@inject()
9-
@injectHelper(AuthRouter, UserRouter, TeamRouter)
10+
@injectHelper(AccountsRouter, UserRouter, TeamRouter, GuestRouter)
1011
export class AppRouter {
1112
constructor(
12-
private readonly authRouter: AuthRouter,
13+
private readonly accountsRouter: AccountsRouter,
1314
private readonly userRouter: UserRouter,
1415
private readonly teamRouter: TeamRouter,
16+
private readonly guestRouter: GuestRouter,
1517
) {}
1618

1719
getRouter() {
1820
return router({
19-
auth: this.authRouter.getRouter(),
21+
accounts: this.accountsRouter.getRouter(),
2022
user: this.userRouter.getRouter(),
2123
teams: this.teamRouter.getRouter(),
24+
guestLogin: this.guestRouter.getRouter(),
2225
});
2326
}
2427
}

apps/api/app/routers/guest_router.ts

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { inject } from '@adonisjs/core';
2+
import { z } from 'zod';
3+
import { injectHelper } from '../../util/inject_helper.js';
4+
import { GuestPasswordService } from '../guest_password/guest_password_service.js';
5+
import { TeamSchema } from '../team/schemas/team_schema.js';
6+
import { publicProcedure, router } from '../trpc/trpc_service.js';
7+
8+
@inject()
9+
@injectHelper(GuestPasswordService)
10+
export class GuestRouter {
11+
constructor(private readonly guestPasswordService: GuestPasswordService) {}
12+
13+
getRouter() {
14+
return router({
15+
isGuest: publicProcedure
16+
.input(TeamSchema.pick({ slug: true }))
17+
.output(z.boolean())
18+
.query(({ ctx, input }) => {
19+
if (!ctx.guestToken) {
20+
return false;
21+
}
22+
23+
return this.guestPasswordService.teamHasGuestToken(input, ctx.guestToken);
24+
}),
25+
passwordLogin: publicProcedure
26+
.input(TeamSchema.pick({ password: true, slug: true }))
27+
.mutation(async ({ ctx, input }) => {
28+
await this.guestPasswordService.guestPasswordLogin(input, ctx.context);
29+
}),
30+
});
31+
}
32+
}

0 commit comments

Comments
 (0)