Skip to content
Closed
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
39 changes: 33 additions & 6 deletions shared/authentication/src/auth.definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
} from 'better-auth/plugins';
import { eq, sql } from 'drizzle-orm';
import { WXYCRoles } from './auth.roles';
import { sendResetPasswordEmail, sendVerificationEmailMessage } from './email';
import { sendEmail, sendVerificationEmailMessage } from './email';

const buildResetUrl = (url: string, redirectTo?: string) => {
if (!redirectTo) {
Expand Down Expand Up @@ -76,11 +76,24 @@ export const auth: Auth = betterAuth({
const redirectTo = process.env.PASSWORD_RESET_REDIRECT_URL?.trim();
const resetUrl = buildResetUrl(url, redirectTo);

void sendResetPasswordEmail({
// Detect if this is a new user setup or actual password reset
// New users created by admin don't have realName filled in yet
const userWithCustomFields = user as typeof user & {
realName?: string | null;
};
const isNewUserSetup =
!userWithCustomFields.realName ||
(typeof userWithCustomFields.realName === 'string' &&
userWithCustomFields.realName.trim() === '');

const emailType = isNewUserSetup ? 'accountSetup' : 'passwordReset';

void sendEmail({
type: emailType,
to: user.email,
resetUrl,
url: resetUrl,
}).catch((error) => {
console.error('Error sending password reset email:', error);
console.error(`Error sending ${emailType} email:`, error);
});
},
onPasswordReset: async ({ user }, request) => {
Expand Down Expand Up @@ -118,7 +131,7 @@ export const auth: Auth = betterAuth({
jwt({
// JWT plugin configuration
// JWKS endpoint automatically exposed at /api/auth/jwks
// Custom payload to include organization member role
// Custom payload to include organization member role and capabilities
jwt: {
definePayload: async ({ user }) => {
// Query organization membership to get member role
Expand All @@ -130,14 +143,26 @@ export const auth: Auth = betterAuth({
.limit(1);

if (memberRecord.length > 0) {
// Cast user to access capabilities field
const userWithCapabilities = user as typeof user & {
capabilities?: string[] | null;
};
return {
...user,
role: memberRecord[0].role, // Use organization member role instead of default user role
capabilities: userWithCapabilities.capabilities ?? [], // Include capabilities in JWT
};
}
}
// Fallback to default user data if no organization membership found
return user;
// Still include capabilities even without organization membership
const userWithCapabilities = user as typeof user & {
capabilities?: string[] | null;
};
return {
...user,
capabilities: userWithCapabilities?.capabilities ?? [],
};
},
},
}),
Expand Down Expand Up @@ -344,6 +369,8 @@ export const auth: Auth = betterAuth({
djName: { type: 'string', required: false },
appSkin: { type: 'string', required: true, defaultValue: 'modern-light' },
isAnonymous: { type: 'boolean', required: false, defaultValue: false },
// Cross-cutting capabilities independent of role hierarchy (e.g., 'editor', 'webmaster')
capabilities: { type: 'string[]', required: false, defaultValue: [] },
},
},
});
11 changes: 11 additions & 0 deletions shared/authentication/src/auth.roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const statement = {
catalog: ["read", "write"],
bin: ["read", "write"],
flowsheet: ["read", "write"],
roster: ["read", "write"],
} as const;

export type AccessControlStatement = typeof statement;
Expand Down Expand Up @@ -38,13 +39,23 @@ export const stationManager = accessControl.newRole({
bin: ["read", "write"],
catalog: ["read", "write"],
flowsheet: ["read", "write"],
roster: ["read", "write"],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there a roster?

});

export const admin = accessControl.newRole({
...adminAc.statements,
bin: ["read", "write"],
catalog: ["read", "write"],
flowsheet: ["read", "write"],
roster: ["read", "write"],
});

export const WXYCRoles = {
member,
dj,
musicDirector,
stationManager,
admin,
};

export type WXYCRole = keyof typeof WXYCRoles;
135 changes: 80 additions & 55 deletions shared/authentication/src/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,31 +25,68 @@ const getSesClient = () => {
return sesClient;
};

type ResetEmailInput = {
to: string;
resetUrl: string;
};

type VerificationEmailInput = {
to: string;
verificationUrl: string;
};

type EmailTemplateInput = {
subject: string;
title: string;
intro: string;
actionText: string;
actionUrl: string;
footer?: string;
};

// Discriminated union for all transactional emails
export type WXYCEmail =
| { type: 'passwordReset'; to: string; url: string }
| { type: 'emailVerification'; to: string; url: string }
| { type: 'accountSetup'; to: string; url: string };

/**
* Content factory for each email type
*/
function getEmailContent(
type: WXYCEmail['type'],
url: string,
orgName: string
): EmailTemplateInput {
switch (type) {
case 'passwordReset':
return {
subject: 'Reset your password',
title: 'Reset your password',
intro:
'We received a request to reset your password. Use the button below to continue.',
actionText: 'Reset password',
actionUrl: url,
};

case 'emailVerification':
return {
subject: `Welcome to ${orgName}! Verify your email address`,
title: 'Verify your email address',
intro: `Welcome to ${orgName}! Please verify your email address to activate your account.`,
actionText: 'Verify email',
actionUrl: url,
};

case 'accountSetup':
return {
subject: `Welcome to ${orgName}! Set up your password`,
title: 'Welcome! Set up your account',
intro: `You've been added to ${orgName}. Click below to set your password and get started.`,
actionText: 'Set up password',
actionUrl: url,
footer: `You're receiving this because an administrator added you to ${orgName}. If you didn't expect this, please contact your station manager.`,
};
}
}

const buildEmailHtml = ({
title,
intro,
actionText,
actionUrl,
footer,
}: EmailTemplateInput) => `
}: Omit<EmailTemplateInput, 'subject'>) => `
<div style="background-color:#0b0a10;padding:24px 12px;font-family:Arial,Helvetica,sans-serif;color:#fce7f3;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:600px;margin:0 auto;background:#14101a;border-radius:12px;overflow:hidden;">
<tr>
Expand Down Expand Up @@ -87,29 +124,26 @@ const buildEmailHtml = ({
</div>
`.trim();

export const sendResetPasswordEmail = async ({
to,
resetUrl,
}: ResetEmailInput) => {
/**
* Send a transactional email using the unified email system
*/
export async function sendEmail(email: WXYCEmail): Promise<void> {
const from = process.env.SES_FROM_EMAIL;
if (!from) {
throw new Error('Missing AWS SES configuration: SES_FROM_EMAIL');
}

const subject = 'Reset your password';
const textBody = `Click the link to reset your password: ${resetUrl}`;
const htmlBody = buildEmailHtml({
title: 'Reset your password',
intro: 'We received a request to reset your password. Use the button below to continue.',
actionText: 'Reset password',
actionUrl: resetUrl,
});
const orgName = process.env.DEFAULT_ORG_NAME || 'WXYC';
const content = getEmailContent(email.type, email.url, orgName);

const textBody = `${content.intro} ${content.actionUrl}`;
const htmlBody = buildEmailHtml(content);

const command = new SendEmailCommand({
Source: from,
Destination: { ToAddresses: [to] },
Destination: { ToAddresses: [email.to] },
Message: {
Subject: { Data: subject },
Subject: { Data: content.subject },
Body: {
Text: { Data: textBody },
Html: { Data: htmlBody },
Expand All @@ -119,38 +153,29 @@ export const sendResetPasswordEmail = async ({

const client = getSesClient();
await client.send(command);
};
}

// Backward-compatible wrappers
export const sendResetPasswordEmail = async ({
to,
resetUrl,
}: {
to: string;
resetUrl: string;
}) => sendEmail({ type: 'passwordReset', to, url: resetUrl });

export const sendVerificationEmailMessage = async ({
to,
verificationUrl,
}: VerificationEmailInput) => {
const from = process.env.SES_FROM_EMAIL;
if (!from) {
throw new Error('Missing AWS SES configuration: SES_FROM_EMAIL');
}

const subject = 'Welcome to ' + process.env.DEFAULT_ORG_NAME + '! Verify your email address';
const textBody = `Click the link to verify your email: ${verificationUrl}`;
const htmlBody = buildEmailHtml({
title: 'Verify your email address',
intro: `Welcome to ${process.env.DEFAULT_ORG_NAME}! Please verify your email address to activate your account.`,
actionText: 'Verify email',
actionUrl: verificationUrl,
});

const command = new SendEmailCommand({
Source: from,
Destination: { ToAddresses: [to] },
Message: {
Subject: { Data: subject },
Body: {
Text: { Data: textBody },
Html: { Data: htmlBody },
},
},
});
}: {
to: string;
verificationUrl: string;
}) => sendEmail({ type: 'emailVerification', to, url: verificationUrl });

const client = getSesClient();
await client.send(command);
};
export const sendAccountSetupEmail = async ({
to,
setupUrl,
}: {
to: string;
setupUrl: string;
}) => sendEmail({ type: 'accountSetup', to, url: setupUrl });
4 changes: 4 additions & 0 deletions shared/database/src/migrations/0026_capabilities_column.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Add capabilities column to auth_user table
-- Capabilities are cross-cutting permissions independent of role hierarchy (e.g., 'editor', 'webmaster')
--> statement-breakpoint
ALTER TABLE "auth_user" ADD COLUMN "capabilities" text[] DEFAULT '{}' NOT NULL;
7 changes: 7 additions & 0 deletions shared/database/src/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@
"when": 1768890229444,
"tag": "0025_rate_limiting_tables",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1769990400000,
"tag": "0026_capabilities_column",
"breakpoints": true
}
]
}
2 changes: 2 additions & 0 deletions shared/database/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export const user = pgTable(
djName: varchar('dj_name', { length: 255 }),
appSkin: varchar('app_skin', { length: 255 }).notNull().default('modern-light'),
isAnonymous: boolean('is_anonymous').notNull().default(false),
// Cross-cutting capabilities independent of role hierarchy (e.g., 'editor', 'webmaster')
capabilities: text('capabilities').array().notNull().default([]),
},
(table) => [
uniqueIndex('auth_user_email_key').on(table.email),
Expand Down
21 changes: 20 additions & 1 deletion tests/mocks/database.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,26 @@ export const library_artist_view = {};
export const flowsheet = { id: 'id', show_id: 'show_id', album_id: 'album_id', entry_type: 'entry_type', track_title: 'track_title', album_title: 'album_title', artist_name: 'artist_name', record_label: 'record_label', rotation_id: 'rotation_id', play_order: 'play_order', request_flag: 'request_flag', message: 'message', add_time: 'add_time' };
export const shows = {};
export const show_djs = {};
export const user = {};
export const user = {
id: 'id',
name: 'name',
email: 'email',
emailVerified: 'email_verified',
image: 'image',
createdAt: 'created_at',
updatedAt: 'updated_at',
role: 'role',
banned: 'banned',
banReason: 'ban_reason',
banExpires: 'ban_expires',
username: 'username',
displayUsername: 'display_username',
realName: 'real_name',
djName: 'dj_name',
appSkin: 'app_skin',
isAnonymous: 'is_anonymous',
capabilities: 'capabilities',
};
export const specialty_shows = {};

// Mock enum
Expand Down
Loading
Loading