feat(auth): add organization management endpoints

Add missing organization features for Teams functionality:
- PUT /auth/organizations/:id - update organization
- DELETE /auth/organizations/:id - delete organization
- PATCH /auth/organizations/:orgId/members/:memberId/role - update member role
- GET /auth/organizations/:id/invitations - list org invitations
- GET /auth/invitations - list user invitations
- DELETE /auth/invitations/:id - cancel or reject invitation
This commit is contained in:
Till-JS 2026-02-16 12:43:38 +01:00
parent 9d618b107c
commit 5fe16b5eec
13 changed files with 1163 additions and 0 deletions

View file

@ -0,0 +1,27 @@
{
"name": "@manacore/shared-pwa",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"@vite-pwa/sveltekit": ">=0.6.0"
},
"devDependencies": {
"@vite-pwa/sveltekit": "^0.6.5",
"typescript": "^5.0.0",
"vite": "^6.0.0",
"workbox-build": "^7.0.0"
}
}

View file

@ -0,0 +1,93 @@
#!/usr/bin/env node
/**
* PWA Icon Generator Script
*
* Generates PWA icons from a source SVG or PNG file.
* Creates: pwa-192x192.png, pwa-512x512.png, apple-touch-icon.png
*
* Usage:
* node generate-icons.mjs <source-image> [output-dir]
*
* Requirements:
* - sharp package (installed as devDependency)
*
* Example:
* node generate-icons.mjs favicon.svg static/
*/
import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ICON_SIZES = [
{ name: 'pwa-192x192.png', size: 192 },
{ name: 'pwa-512x512.png', size: 512 },
{ name: 'apple-touch-icon.png', size: 180 },
];
async function generateIcons(sourcePath, outputDir) {
// Dynamic import of sharp (may not be installed in all contexts)
let sharp;
try {
sharp = (await import('sharp')).default;
} catch {
console.error('Error: sharp package not installed.');
console.error('Install it with: pnpm add -D sharp');
process.exit(1);
}
if (!existsSync(sourcePath)) {
console.error(`Error: Source file not found: ${sourcePath}`);
process.exit(1);
}
// Create output directory if it doesn't exist
if (!existsSync(outputDir)) {
mkdirSync(outputDir, { recursive: true });
}
console.log(`Generating PWA icons from: ${sourcePath}`);
console.log(`Output directory: ${outputDir}`);
console.log('');
for (const icon of ICON_SIZES) {
const outputPath = join(outputDir, icon.name);
try {
await sharp(sourcePath).resize(icon.size, icon.size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toFile(outputPath);
console.log(`${icon.name} (${icon.size}x${icon.size})`);
} catch (error) {
console.error(`${icon.name}: ${error.message}`);
}
}
console.log('');
console.log('Done! Icons generated successfully.');
console.log('');
console.log('Make sure these files are in your static/ directory.');
}
// CLI execution
const args = process.argv.slice(2);
if (args.length < 1) {
console.log('PWA Icon Generator');
console.log('');
console.log('Usage: node generate-icons.mjs <source-image> [output-dir]');
console.log('');
console.log('Arguments:');
console.log(' source-image Path to source SVG or PNG file');
console.log(' output-dir Output directory (default: current directory)');
console.log('');
console.log('Example:');
console.log(' node generate-icons.mjs favicon.svg static/');
process.exit(0);
}
const sourcePath = args[0];
const outputDir = args[1] || '.';
generateIcons(sourcePath, outputDir);

View file

@ -0,0 +1,135 @@
/**
* PWA Configuration Factory
*
* Creates a complete @vite-pwa/sveltekit configuration with sensible defaults
* and preset-based caching strategies.
*
* @example
* ```ts
* import { createPWAConfig } from '@manacore/shared-pwa';
* import { SvelteKitPWA } from '@vite-pwa/sveltekit';
*
* export default defineConfig({
* plugins: [
* sveltekit(),
* SvelteKitPWA(createPWAConfig({
* name: 'Calendar - Kalender',
* shortName: 'Calendar',
* description: 'Kalender mit Offline-Unterstützung',
* themeColor: '#3b82f6',
* preset: 'standard',
* })),
* ],
* });
* ```
*/
import type { PWAConfigOptions, PWAConfig, ManifestConfig, WorkboxConfig } from './types.js';
import {
DEFAULT_BACKGROUND_COLOR,
DEFAULT_CATEGORIES,
DEFAULT_INCLUDE_ASSETS,
DEFAULT_GLOB_PATTERNS,
DEFAULT_GLOB_IGNORES,
DEFAULT_NAVIGATE_FALLBACK_DENYLIST,
DEFAULT_ICONS,
} from './defaults.js';
import { getPresetRuntimeCaching } from './presets.js';
/**
* Create a complete PWA configuration for SvelteKit apps
*/
export function createPWAConfig(options: PWAConfigOptions): PWAConfig {
const {
name,
shortName,
description,
themeColor,
backgroundColor = DEFAULT_BACKGROUND_COLOR,
preset = 'standard',
shortcuts = [],
categories = DEFAULT_CATEGORIES,
includeAssets = [],
globIgnores = [],
additionalRuntimeCaching = [],
navigateFallback = '/offline',
navigateFallbackDenylist = DEFAULT_NAVIGATE_FALLBACK_DENYLIST,
devEnabled = true,
registerType = 'autoUpdate',
lang = 'de',
startUrl = '/',
} = options;
// Build manifest
const manifest: ManifestConfig = {
name,
short_name: shortName,
description,
theme_color: themeColor,
background_color: backgroundColor,
display: 'standalone',
orientation: 'any',
scope: '/',
start_url: startUrl,
lang,
categories,
icons: DEFAULT_ICONS,
};
// Add shortcuts if provided
if (shortcuts.length > 0) {
manifest.shortcuts = shortcuts.map((shortcut) => ({
name: shortcut.name,
short_name: shortcut.short_name,
description: shortcut.description,
url: shortcut.url,
icons: [{ src: 'pwa-192x192.png', sizes: '192x192' }],
}));
}
// Build workbox config
const workbox: WorkboxConfig = {
globPatterns: DEFAULT_GLOB_PATTERNS,
globIgnores: [...DEFAULT_GLOB_IGNORES, ...globIgnores],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
navigateFallback,
navigateFallbackDenylist,
runtimeCaching: [...getPresetRuntimeCaching(preset), ...additionalRuntimeCaching],
};
// Return complete config
return {
registerType,
devOptions: {
enabled: devEnabled,
},
includeAssets: [...DEFAULT_INCLUDE_ASSETS, ...includeAssets],
manifest,
workbox,
};
}
/**
* Create PWA config with SQLite WASM support (for offline-first apps)
* Adds proper glob ignores and OPFS configuration
*/
export function createOfflineFirstPWAConfig(
options: PWAConfigOptions & {
/**
* Additional packages to exclude from precaching
*/
excludePackages?: string[];
}
): PWAConfig {
const { excludePackages = [], globIgnores = [], ...rest } = options;
// Add SQLite-specific ignores
const allGlobIgnores = ['**/*sqlite*', '**/*wasm*', ...excludePackages.map((pkg) => `**/${pkg}/**`), ...globIgnores];
return createPWAConfig({
...rest,
globIgnores: allGlobIgnores,
});
}

View file

@ -0,0 +1,66 @@
/**
* Default PWA Configuration Values
*/
import type { ManifestIcon } from './types.js';
/**
* Default dark background color for ManaCore apps
*/
export const DEFAULT_BACKGROUND_COLOR = '#09090b';
/**
* Default app categories
*/
export const DEFAULT_CATEGORIES = ['productivity', 'utilities'];
/**
* Default assets to include in PWA
*/
export const DEFAULT_INCLUDE_ASSETS = ['favicon.png', 'favicon.svg'];
/**
* Default glob patterns for precaching
*/
export const DEFAULT_GLOB_PATTERNS = ['**/*.{js,css,html,ico,png,svg,woff,woff2}'];
/**
* Default URL patterns to exclude from navigate fallback
*/
export const DEFAULT_NAVIGATE_FALLBACK_DENYLIST = [/^\/api/, /^\/auth/];
/**
* Default glob ignores (SQLite WASM for offline-first apps)
*/
export const DEFAULT_GLOB_IGNORES = ['**/*sqlite*'];
/**
* Standard PWA icon configuration
*/
export const DEFAULT_ICONS: ManifestIcon[] = [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
];
/**
* Apple touch icon configuration
*/
export const APPLE_TOUCH_ICON = {
src: 'apple-touch-icon.png',
sizes: '180x180',
type: 'image/png',
};

View file

@ -0,0 +1,53 @@
/**
* @manacore/shared-pwa
*
* Unified PWA configuration for all ManaCore SvelteKit apps.
* Provides factory functions, presets, and defaults for consistent PWA setup.
*
* @example
* ```ts
* import { createPWAConfig } from '@manacore/shared-pwa';
* import { SvelteKitPWA } from '@vite-pwa/sveltekit';
*
* export default defineConfig({
* plugins: [
* sveltekit(),
* SvelteKitPWA(createPWAConfig({
* name: 'My App',
* shortName: 'MyApp',
* description: 'My awesome app',
* themeColor: '#3b82f6',
* })),
* ],
* });
* ```
*/
// Main factory functions
export { createPWAConfig, createOfflineFirstPWAConfig } from './config.js';
// Presets and cache strategies
export { getPresetRuntimeCaching, cacheStrategies } from './presets.js';
// Default values
export {
DEFAULT_BACKGROUND_COLOR,
DEFAULT_CATEGORIES,
DEFAULT_INCLUDE_ASSETS,
DEFAULT_GLOB_PATTERNS,
DEFAULT_GLOB_IGNORES,
DEFAULT_NAVIGATE_FALLBACK_DENYLIST,
DEFAULT_ICONS,
APPLE_TOUCH_ICON,
} from './defaults.js';
// Types
export type {
PWAConfigOptions,
PWAConfig,
PWAShortcut,
WorkboxPreset,
ManifestConfig,
ManifestIcon,
WorkboxConfig,
} from './types.js';

View file

@ -0,0 +1,124 @@
/**
* Workbox Runtime Caching Presets
*
* Provides pre-configured caching strategies for different app types:
* - minimal: Static assets only
* - standard: + API + Images
* - full: + Fonts + External resources
*/
import type { RuntimeCaching } from 'workbox-build';
import type { WorkboxPreset } from './types.js';
/**
* API caching strategy - NetworkFirst with fallback
* Used for all *.mana.how API endpoints
*/
const API_CACHE: RuntimeCaching = {
urlPattern: /^https:\/\/.*\.mana\.how\/api\/.*/i,
handler: 'NetworkFirst',
options: {
cacheName: 'api-cache',
expiration: {
maxEntries: 100,
maxAgeSeconds: 60 * 60 * 24, // 24 hours
},
cacheableResponse: {
statuses: [0, 200],
},
},
};
/**
* Image caching strategy - CacheFirst for performance
* Caches images for 30 days
*/
const IMAGE_CACHE: RuntimeCaching = {
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/i,
handler: 'CacheFirst',
options: {
cacheName: 'image-cache',
expiration: {
maxEntries: 200,
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
},
},
};
/**
* Font caching strategy - CacheFirst with long expiration
* For Google Fonts and other web fonts
*/
const FONT_CACHE: RuntimeCaching = {
urlPattern: /^https:\/\/fonts\.(?:googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'font-cache',
expiration: {
maxEntries: 30,
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
},
cacheableResponse: {
statuses: [0, 200],
},
},
};
/**
* External resources caching - StaleWhileRevalidate
* For CDN resources and external APIs
*/
const EXTERNAL_CACHE: RuntimeCaching = {
urlPattern: /^https:\/\/cdn\..*/i,
handler: 'StaleWhileRevalidate',
options: {
cacheName: 'external-cache',
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
},
cacheableResponse: {
statuses: [0, 200],
},
},
};
/**
* Preset configurations for different caching strategies
*/
const PRESETS: Record<WorkboxPreset, RuntimeCaching[]> = {
/**
* Minimal preset - Only static assets (precached)
* Use for simple apps without API calls
*/
minimal: [],
/**
* Standard preset - Static + API + Images
* Recommended for most apps
*/
standard: [API_CACHE, IMAGE_CACHE],
/**
* Full preset - Standard + Fonts + External resources
* Use for apps with custom fonts or external CDN resources
*/
full: [API_CACHE, IMAGE_CACHE, FONT_CACHE, EXTERNAL_CACHE],
};
/**
* Get runtime caching rules for a preset
*/
export function getPresetRuntimeCaching(preset: WorkboxPreset): RuntimeCaching[] {
return PRESETS[preset] ?? PRESETS.standard;
}
/**
* Export individual cache strategies for custom configurations
*/
export const cacheStrategies = {
api: API_CACHE,
images: IMAGE_CACHE,
fonts: FONT_CACHE,
external: EXTERNAL_CACHE,
};

View file

@ -0,0 +1,180 @@
/**
* PWA Configuration Types for ManaCore Apps
*/
import type { SvelteKitPWAOptions } from '@vite-pwa/sveltekit';
import type { RuntimeCaching, ManifestEntry } from 'workbox-build';
/**
* Workbox preset types for different caching strategies
*/
export type WorkboxPreset = 'minimal' | 'standard' | 'full';
/**
* PWA manifest shortcut
*/
export interface PWAShortcut {
name: string;
short_name?: string;
description?: string;
url: string;
}
/**
* Configuration options for createPWAConfig
*/
export interface PWAConfigOptions {
/**
* Full name of the app (displayed in install prompts, app switcher)
* @example "Calendar - Kalender"
*/
name: string;
/**
* Short name for home screen icons (max ~12 chars)
* @example "Calendar"
*/
shortName: string;
/**
* App description for store listings
*/
description: string;
/**
* Primary theme color (address bar, splash screen)
* @example "#3b82f6"
*/
themeColor: string;
/**
* Background color for splash screen
* @default "#09090b"
*/
backgroundColor?: string;
/**
* Workbox caching preset
* - minimal: Only static assets (simple apps without API)
* - standard: + API (NetworkFirst) + Images (CacheFirst)
* - full: + Fonts + External Resources
* @default "standard"
*/
preset?: WorkboxPreset;
/**
* App shortcuts for quick actions
*/
shortcuts?: PWAShortcut[];
/**
* App categories for store listings
* @default ["productivity", "utilities"]
*/
categories?: string[];
/**
* Additional assets to include (besides default icons)
*/
includeAssets?: string[];
/**
* Additional glob patterns to ignore in precaching
*/
globIgnores?: string[];
/**
* Additional runtime caching rules
*/
additionalRuntimeCaching?: RuntimeCaching[];
/**
* Custom navigate fallback path
* @default "/offline"
*/
navigateFallback?: string;
/**
* URL patterns to exclude from navigate fallback
* @default [/^\/api/, /^\/auth/]
*/
navigateFallbackDenylist?: RegExp[];
/**
* Enable PWA in development mode
* @default true
*/
devEnabled?: boolean;
/**
* Service worker register type
* @default "autoUpdate"
*/
registerType?: 'autoUpdate' | 'prompt';
/**
* App language
* @default "de"
*/
lang?: string;
/**
* Start URL when app is launched
* @default "/"
*/
startUrl?: string;
}
/**
* Internal manifest icon configuration
*/
export interface ManifestIcon {
src: string;
sizes: string;
type: string;
purpose?: 'any' | 'maskable' | 'monochrome';
}
/**
* Full manifest configuration
*/
export interface ManifestConfig {
name: string;
short_name: string;
description: string;
theme_color: string;
background_color: string;
display: 'standalone' | 'fullscreen' | 'minimal-ui' | 'browser';
orientation: 'any' | 'portrait' | 'landscape';
scope: string;
start_url: string;
lang: string;
categories: string[];
icons: ManifestIcon[];
shortcuts?: Array<{
name: string;
short_name?: string;
description?: string;
url: string;
icons?: Array<{ src: string; sizes: string }>;
}>;
}
/**
* Workbox configuration subset
*/
export interface WorkboxConfig {
globPatterns: string[];
globIgnores?: string[];
cleanupOutdatedCaches: boolean;
clientsClaim: boolean;
skipWaiting: boolean;
navigateFallback: string;
navigateFallbackDenylist: RegExp[];
runtimeCaching: RuntimeCaching[];
}
/**
* Complete PWA configuration result
*/
export type PWAConfig = SvelteKitPWAOptions;

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -2,6 +2,8 @@ import {
Controller, Controller,
Post, Post,
Get, Get,
Put,
Patch,
Delete, Delete,
Body, Body,
Param, Param,
@ -29,6 +31,8 @@ import { ResendVerificationDto } from './dto/resend-verification.dto';
import { UpdateProfileDto } from './dto/update-profile.dto'; import { UpdateProfileDto } from './dto/update-profile.dto';
import { ChangePasswordDto } from './dto/change-password.dto'; import { ChangePasswordDto } from './dto/change-password.dto';
import { DeleteAccountDto } from './dto/delete-account.dto'; import { DeleteAccountDto } from './dto/delete-account.dto';
import { UpdateOrganizationDto } from './dto/update-organization.dto';
import { UpdateMemberRoleDto } from './dto/update-member-role.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator'; import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator'; import type { CurrentUserData } from '../common/decorators/current-user.decorator';
@ -534,6 +538,162 @@ export class AuthController {
}); });
} }
/**
* Update organization
*
* Updates an organization's name, logo, or metadata.
* Requires owner or admin role.
*/
@Put('organizations/:id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Update organization',
description: 'Update organization name, logo, or metadata. Requires admin or owner role.',
})
@ApiBody({ type: UpdateOrganizationDto })
@ApiResponse({ status: 200, description: 'Organization updated successfully' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
@ApiResponse({ status: 403, description: 'No permission to update organization' })
@ApiResponse({ status: 404, description: 'Organization not found' })
async updateOrganization(
@Param('id') id: string,
@Body() dto: UpdateOrganizationDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.updateOrganization(id, dto, token);
}
/**
* Delete organization
*
* Permanently deletes an organization and all its data.
* Requires owner role.
*/
@Delete('organizations/:id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Delete organization',
description: 'Permanently delete an organization. Only the owner can delete.',
})
@ApiResponse({ status: 204, description: 'Organization deleted successfully' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
@ApiResponse({ status: 403, description: 'Only owner can delete organization' })
@ApiResponse({ status: 404, description: 'Organization not found' })
async deleteOrganization(
@Param('id') id: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
await this.betterAuthService.deleteOrganization(id, token);
}
/**
* Update member role
*
* Changes a member's role within an organization.
* Requires owner or admin role.
*/
@Patch('organizations/:orgId/members/:memberId/role')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Update member role',
description: "Change a member's role. Requires admin or owner role.",
})
@ApiBody({ type: UpdateMemberRoleDto })
@ApiResponse({ status: 200, description: 'Member role updated successfully' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
@ApiResponse({ status: 403, description: 'No permission to change roles' })
@ApiResponse({ status: 404, description: 'Member not found' })
async updateMemberRole(
@Param('orgId') orgId: string,
@Param('memberId') memberId: string,
@Body() dto: UpdateMemberRoleDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.updateMemberRole(orgId, memberId, dto.role, token);
}
/**
* List organization invitations
*
* Returns all pending invitations for an organization.
* Requires owner or admin role.
*/
@Get('organizations/:id/invitations')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'List organization invitations',
description: 'Get all pending invitations for an organization.',
})
@ApiResponse({ status: 200, description: 'Returns list of invitations' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
async listOrganizationInvitations(
@Param('id') id: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.listOrganizationInvitations(id, token);
}
/**
* List user's pending invitations
*
* Returns all pending invitations for the authenticated user.
*/
@Get('invitations')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'List user invitations',
description: 'Get all pending invitations for the current user.',
})
@ApiResponse({ status: 200, description: 'Returns list of invitations' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
async listUserInvitations(@Headers('authorization') authorization: string) {
const token = this.extractToken(authorization);
return this.betterAuthService.listUserInvitations(token);
}
/**
* Cancel or reject invitation
*
* Cancels an invitation (for org admins) or rejects it (for invitees).
* The system automatically determines which action to take based on the user's role.
*/
@Delete('invitations/:id')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.NO_CONTENT)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'Cancel or reject invitation',
description:
'Cancel (as org admin/owner) or reject (as invitee) a pending invitation.',
})
@ApiResponse({ status: 204, description: 'Invitation cancelled/rejected successfully' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
@ApiResponse({ status: 404, description: 'Invitation not found' })
async cancelOrRejectInvitation(
@Param('id') id: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
// Try cancel first (for org owners/admins), if fails try reject (for invitees)
try {
await this.betterAuthService.cancelInvitation(id, token);
} catch {
await this.betterAuthService.rejectInvitation(id, token);
}
}
// ========================================================================= // =========================================================================
// Helper Methods // Helper Methods
// ========================================================================= // =========================================================================

View file

@ -14,6 +14,8 @@ export { RegisterB2BDto } from './register-b2b.dto';
export { InviteEmployeeDto } from './invite-employee.dto'; export { InviteEmployeeDto } from './invite-employee.dto';
export { AcceptInvitationDto } from './accept-invitation.dto'; export { AcceptInvitationDto } from './accept-invitation.dto';
export { SetActiveOrganizationDto } from './set-active-organization.dto'; export { SetActiveOrganizationDto } from './set-active-organization.dto';
export { UpdateOrganizationDto } from './update-organization.dto';
export { UpdateMemberRoleDto } from './update-member-role.dto';
// Password management DTOs // Password management DTOs
export { ForgotPasswordDto } from './forgot-password.dto'; export { ForgotPasswordDto } from './forgot-password.dto';

View file

@ -0,0 +1,19 @@
import { IsString, IsIn } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
/**
* DTO for updating a member's role within an organization
*
* Note: 'owner' role cannot be assigned via this endpoint.
* To transfer ownership, use the dedicated transfer ownership endpoint.
*/
export class UpdateMemberRoleDto {
@ApiProperty({
description: 'New role for the member',
enum: ['admin', 'member'],
example: 'admin',
})
@IsString()
@IsIn(['admin', 'member'])
role: 'admin' | 'member';
}

View file

@ -0,0 +1,38 @@
import { IsString, IsOptional, MaxLength, MinLength } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
/**
* DTO for updating an organization
*
* All fields are optional - only provided fields will be updated.
*/
export class UpdateOrganizationDto {
@ApiPropertyOptional({
description: 'New name for the organization',
minLength: 2,
maxLength: 255,
example: 'Acme Corporation',
})
@IsString()
@IsOptional()
@MinLength(2)
@MaxLength(255)
name?: string;
@ApiPropertyOptional({
description: 'URL to organization logo',
maxLength: 500,
example: 'https://example.com/logo.png',
})
@IsString()
@IsOptional()
@MaxLength(500)
logo?: string;
@ApiPropertyOptional({
description: 'Additional metadata for the organization',
example: { industry: 'Technology', size: 'Enterprise' },
})
@IsOptional()
metadata?: Record<string, unknown>;
}

View file

@ -58,6 +58,7 @@ import type {
ValidateTokenResult, ValidateTokenResult,
TokenPayload, TokenPayload,
OrganizationMember, OrganizationMember,
OrganizationInvitation,
Organization, Organization,
BetterAuthAPI, BetterAuthAPI,
SignUpResponse, SignUpResponse,
@ -721,6 +722,252 @@ export class BetterAuthService {
} }
} }
/**
* Update organization
*
* Updates an organization's name, logo, or metadata.
* Requires owner or admin role.
*
* @param organizationId - Organization ID
* @param data - Fields to update (name, logo, metadata)
* @param token - User's authentication token
* @returns Updated organization
* @throws ForbiddenException if user lacks permission
* @throws NotFoundException if organization not found
*/
async updateOrganization(
organizationId: string,
data: { name?: string; logo?: string; metadata?: Record<string, unknown> },
token: string
): Promise<Organization> {
try {
const result = await (this.orgApi as any).updateOrganization({
body: {
organizationId,
data: {
...(data.name !== undefined && { name: data.name }),
...(data.logo !== undefined && { logo: data.logo }),
...(data.metadata !== undefined && { metadata: data.metadata }),
},
},
headers: {
authorization: `Bearer ${token}`,
},
});
return result;
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Organization not found');
}
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('You do not have permission to update this organization');
}
}
throw error;
}
}
/**
* Delete organization
*
* Deletes an organization and all its data.
* Requires owner role.
*
* @param organizationId - Organization ID
* @param token - User's authentication token
* @throws ForbiddenException if user is not the owner
* @throws NotFoundException if organization not found
*/
async deleteOrganization(organizationId: string, token: string): Promise<void> {
try {
await (this.orgApi as any).deleteOrganization({
body: { organizationId },
headers: {
authorization: `Bearer ${token}`,
},
});
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Organization not found');
}
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('Only the owner can delete the organization');
}
}
throw error;
}
}
/**
* Update member role
*
* Changes a member's role within an organization.
* Requires owner or admin role.
*
* @param organizationId - Organization ID
* @param memberId - Member ID to update
* @param role - New role ('admin' or 'member')
* @param token - User's authentication token
* @returns Updated member
* @throws ForbiddenException if user lacks permission
* @throws NotFoundException if member not found
*/
async updateMemberRole(
organizationId: string,
memberId: string,
role: 'admin' | 'member',
token: string
): Promise<OrganizationMember> {
try {
const result = await (this.orgApi as any).updateMemberRole({
body: {
organizationId,
memberId,
role,
},
headers: {
authorization: `Bearer ${token}`,
},
});
return result?.member || result;
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Member not found');
}
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('You do not have permission to change member roles');
}
if (error.message?.includes('owner')) {
throw new ForbiddenException("Cannot change the owner's role");
}
}
throw error;
}
}
/**
* List organization invitations
*
* Returns all pending invitations for an organization.
* Requires owner or admin role.
*
* @param organizationId - Organization ID
* @param token - User's authentication token
* @returns List of invitations
*/
async listOrganizationInvitations(
organizationId: string,
token: string
): Promise<OrganizationInvitation[]> {
try {
const result = await (this.orgApi as any).listInvitations({
query: { organizationId },
headers: {
authorization: `Bearer ${token}`,
},
});
return result?.invitations || result || [];
} catch (error: unknown) {
this.logger.error(
'Failed to list organization invitations',
error instanceof Error ? error.stack : undefined
);
return [];
}
}
/**
* List user's pending invitations
*
* Returns all pending invitations for the authenticated user.
*
* @param token - User's authentication token
* @returns List of invitations
*/
async listUserInvitations(token: string): Promise<OrganizationInvitation[]> {
try {
const result = (await (this.orgApi as any).getInvitation)
? await (this.orgApi as any).listUserInvitations({
headers: {
authorization: `Bearer ${token}`,
},
})
: [];
return result?.invitations || result || [];
} catch (error: unknown) {
this.logger.error(
'Failed to list user invitations',
error instanceof Error ? error.stack : undefined
);
return [];
}
}
/**
* Cancel an invitation
*
* Cancels a pending invitation. Used by organization admins/owners.
*
* @param invitationId - Invitation ID
* @param token - User's authentication token
* @throws ForbiddenException if user lacks permission
* @throws NotFoundException if invitation not found
*/
async cancelInvitation(invitationId: string, token: string): Promise<void> {
try {
await (this.orgApi as any).cancelInvitation({
body: { invitationId },
headers: {
authorization: `Bearer ${token}`,
},
});
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Invitation not found');
}
if (error.message?.includes('permission') || error.message?.includes('unauthorized')) {
throw new ForbiddenException('You do not have permission to cancel this invitation');
}
}
throw error;
}
}
/**
* Reject an invitation
*
* Rejects a pending invitation. Used by the invited user.
*
* @param invitationId - Invitation ID
* @param token - User's authentication token
* @throws NotFoundException if invitation not found
*/
async rejectInvitation(invitationId: string, token: string): Promise<void> {
try {
await (this.orgApi as any).rejectInvitation({
body: { invitationId },
headers: {
authorization: `Bearer ${token}`,
},
});
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message?.includes('not found')) {
throw new NotFoundException('Invitation not found');
}
}
throw error;
}
}
// ========================================================================= // =========================================================================
// Token Management Methods // Token Management Methods
// ========================================================================= // =========================================================================