diff --git a/packages/shared-pwa/package.json b/packages/shared-pwa/package.json new file mode 100644 index 000000000..35e680738 --- /dev/null +++ b/packages/shared-pwa/package.json @@ -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" + } +} diff --git a/packages/shared-pwa/scripts/generate-icons.mjs b/packages/shared-pwa/scripts/generate-icons.mjs new file mode 100644 index 000000000..8084c3f77 --- /dev/null +++ b/packages/shared-pwa/scripts/generate-icons.mjs @@ -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 [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 [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); diff --git a/packages/shared-pwa/src/config.ts b/packages/shared-pwa/src/config.ts new file mode 100644 index 000000000..51c60a039 --- /dev/null +++ b/packages/shared-pwa/src/config.ts @@ -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, + }); +} diff --git a/packages/shared-pwa/src/defaults.ts b/packages/shared-pwa/src/defaults.ts new file mode 100644 index 000000000..7d08e6d38 --- /dev/null +++ b/packages/shared-pwa/src/defaults.ts @@ -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', +}; diff --git a/packages/shared-pwa/src/index.ts b/packages/shared-pwa/src/index.ts new file mode 100644 index 000000000..d84853664 --- /dev/null +++ b/packages/shared-pwa/src/index.ts @@ -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'; diff --git a/packages/shared-pwa/src/presets.ts b/packages/shared-pwa/src/presets.ts new file mode 100644 index 000000000..f69e061b4 --- /dev/null +++ b/packages/shared-pwa/src/presets.ts @@ -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 = { + /** + * 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, +}; diff --git a/packages/shared-pwa/src/types.ts b/packages/shared-pwa/src/types.ts new file mode 100644 index 000000000..d38b444d3 --- /dev/null +++ b/packages/shared-pwa/src/types.ts @@ -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; diff --git a/packages/shared-pwa/tsconfig.json b/packages/shared-pwa/tsconfig.json new file mode 100644 index 000000000..b580d869f --- /dev/null +++ b/packages/shared-pwa/tsconfig.json @@ -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"] +} diff --git a/services/mana-core-auth/src/auth/auth.controller.ts b/services/mana-core-auth/src/auth/auth.controller.ts index a50c011da..b9431f0f2 100644 --- a/services/mana-core-auth/src/auth/auth.controller.ts +++ b/services/mana-core-auth/src/auth/auth.controller.ts @@ -2,6 +2,8 @@ import { Controller, Post, Get, + Put, + Patch, Delete, Body, Param, @@ -29,6 +31,8 @@ import { ResendVerificationDto } from './dto/resend-verification.dto'; import { UpdateProfileDto } from './dto/update-profile.dto'; import { ChangePasswordDto } from './dto/change-password.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 { CurrentUser } 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 // ========================================================================= diff --git a/services/mana-core-auth/src/auth/dto/index.ts b/services/mana-core-auth/src/auth/dto/index.ts index 3340d4132..92ab40644 100644 --- a/services/mana-core-auth/src/auth/dto/index.ts +++ b/services/mana-core-auth/src/auth/dto/index.ts @@ -14,6 +14,8 @@ export { RegisterB2BDto } from './register-b2b.dto'; export { InviteEmployeeDto } from './invite-employee.dto'; export { AcceptInvitationDto } from './accept-invitation.dto'; export { SetActiveOrganizationDto } from './set-active-organization.dto'; +export { UpdateOrganizationDto } from './update-organization.dto'; +export { UpdateMemberRoleDto } from './update-member-role.dto'; // Password management DTOs export { ForgotPasswordDto } from './forgot-password.dto'; diff --git a/services/mana-core-auth/src/auth/dto/update-member-role.dto.ts b/services/mana-core-auth/src/auth/dto/update-member-role.dto.ts new file mode 100644 index 000000000..a54842f74 --- /dev/null +++ b/services/mana-core-auth/src/auth/dto/update-member-role.dto.ts @@ -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'; +} diff --git a/services/mana-core-auth/src/auth/dto/update-organization.dto.ts b/services/mana-core-auth/src/auth/dto/update-organization.dto.ts new file mode 100644 index 000000000..7093fecd6 --- /dev/null +++ b/services/mana-core-auth/src/auth/dto/update-organization.dto.ts @@ -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; +} diff --git a/services/mana-core-auth/src/auth/services/better-auth.service.ts b/services/mana-core-auth/src/auth/services/better-auth.service.ts index 8b430b0bf..1012a4c1e 100644 --- a/services/mana-core-auth/src/auth/services/better-auth.service.ts +++ b/services/mana-core-auth/src/auth/services/better-auth.service.ts @@ -58,6 +58,7 @@ import type { ValidateTokenResult, TokenPayload, OrganizationMember, + OrganizationInvitation, Organization, BetterAuthAPI, 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 }, + token: string + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 // =========================================================================