@nuxt-customer-portal/saas-configuration 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +13 -0
  4. package/app/assets/css/main.css +439 -0
  5. package/app/assets/css/themes/brutal.css +453 -0
  6. package/app/assets/css/tokens.css +136 -0
  7. package/app/components/PortalSettingsEditor.vue +452 -0
  8. package/app/components/PublicPortalLogo.vue +26 -0
  9. package/app/components/portal-settings/BrandingStep.vue +198 -0
  10. package/app/components/portal-settings/HomeStep.vue +116 -0
  11. package/app/components/portal-settings/LegalStep.vue +44 -0
  12. package/app/components/portal-settings/ModulesStep.vue +88 -0
  13. package/app/components/portal-settings/ReviewStep.vue +54 -0
  14. package/app/composables/usePortalSettings.ts +25 -0
  15. package/app/middleware/00-portal-onboarding.global.ts +52 -0
  16. package/app/pages/admin/portal-settings.vue +6 -0
  17. package/app/pages/index.vue +170 -0
  18. package/app/pages/onboarding.vue +8 -0
  19. package/app/pages/privacy.vue +46 -0
  20. package/app/pages/terms.vue +46 -0
  21. package/app/plugins/00-portal-settings.ts +26 -0
  22. package/app/plugins/saas-configuration-feature.ts +3 -0
  23. package/i18n/locales/en.json +101 -0
  24. package/i18n/locales/nl.json +105 -0
  25. package/migrations/0000_portal_settings.sql +9 -0
  26. package/nuxt.config.ts +15 -0
  27. package/package.json +50 -0
  28. package/portal.manifest.mjs +7 -0
  29. package/server/api/admin/portal-settings/complete.post.ts +5 -0
  30. package/server/api/admin/portal-settings/index.get.ts +4 -0
  31. package/server/api/admin/portal-settings/index.put.ts +5 -0
  32. package/server/api/portal/bootstrap.get.ts +1 -0
  33. package/server/api/portal/public.get.ts +10 -0
  34. package/server/middleware/01-portal-bootstrap-gate.ts +50 -0
  35. package/server/utils/portal-settings.ts +138 -0
  36. package/shared/feature.ts +30 -0
  37. package/shared/primary-contrast.ts +12 -0
  38. package/shared/settings.ts +146 -0
  39. package/shared/theme.ts +134 -0
@@ -0,0 +1,26 @@
1
+ import { primaryForeground } from '../../shared/primary-contrast'
2
+
3
+ export default defineNuxtPlugin(async () => {
4
+ const { refreshPublicSettings } = usePortalSettings()
5
+ const settings = await refreshPublicSettings().catch(() => null)
6
+ if (!settings) {
7
+ return
8
+ }
9
+ const colorMode = useColorMode()
10
+ if (settings.appearance.colorMode === 'light-only') {
11
+ colorMode.preference = 'light'
12
+ }
13
+ if (settings.appearance.colorMode === 'dark-only') {
14
+ colorMode.preference = 'dark'
15
+ }
16
+
17
+ useHead(() => ({
18
+ htmlAttrs: { 'data-portal-theme': settings.appearance.theme },
19
+ style: [
20
+ {
21
+ key: 'portal-primary-colors',
22
+ innerHTML: `:root{--portal-primary:${settings.appearance.primaryLight};--portal-on-primary:${primaryForeground(settings.appearance.primaryLight)};--ui-primary:${settings.appearance.primaryLight};--color-primary-500:${settings.appearance.primaryLight};--color-primary-600:${settings.appearance.primaryLight}}html.dark{--portal-primary:${settings.appearance.primaryDark};--portal-on-primary:${primaryForeground(settings.appearance.primaryDark)};--ui-primary:${settings.appearance.primaryDark};--color-primary-500:${settings.appearance.primaryDark};--color-primary-600:${settings.appearance.primaryDark}}`
23
+ }
24
+ ]
25
+ }))
26
+ })
@@ -0,0 +1,3 @@
1
+ import { saasConfigurationFeature } from '../../shared/feature'
2
+
3
+ export default defineNuxtPlugin(() => usePortalFeatures().registerFeature(saasConfigurationFeature))
@@ -0,0 +1,101 @@
1
+ {
2
+ "saasSettings": {
3
+ "navigation": "Portal settings",
4
+ "title": "Portal settings",
5
+ "onboardingTitle": "Set up your portal",
6
+ "public": { "myDashboard": "My dashboard" },
7
+ "bootstrap": {
8
+ "title": "Create the portal administrator",
9
+ "description": "This new portal is reserved for its designated administrator. Create that account first; you will configure the portal after signing in."
10
+ },
11
+ "editor": {
12
+ "setupProgress": "Setup progress",
13
+ "portalConfiguration": "Portal configuration",
14
+ "configurationSections": "Configuration sections",
15
+ "loading": "Loading settings…",
16
+ "unavailable": "Settings unavailable",
17
+ "steps": {
18
+ "branding": "Branding and appearance",
19
+ "modules": "Modules",
20
+ "home": "Home page",
21
+ "legal": "Legal pages",
22
+ "review": "Review"
23
+ },
24
+ "appearanceSection": "Appearance",
25
+ "logoSection": "Logos for the selected color mode",
26
+ "fields": {
27
+ "portalName": "Portal name",
28
+ "tagline": "Tagline",
29
+ "supportEmail": "Support email",
30
+ "supportUrl": "Support URL",
31
+ "markLight": "Compact icon for light mode",
32
+ "markDark": "Compact icon for dark mode",
33
+ "logoLight": "Full logo for light mode",
34
+ "logoDark": "Full logo for dark mode",
35
+ "theme": "Theme",
36
+ "colorMode": "Color mode",
37
+ "primaryLight": "Primary color — light",
38
+ "primaryDark": "Primary color — dark",
39
+ "heroTitle": "Hero title",
40
+ "heroDescription": "Hero description",
41
+ "actionLabel": "Action label",
42
+ "actionUrl": "Action URL",
43
+ "introductionTitle": "Introduction title",
44
+ "introduction": "Introduction",
45
+ "supportTitle": "Support title",
46
+ "supportText": "Support text",
47
+ "termsTitle": "Terms title",
48
+ "termsText": "Terms text",
49
+ "privacyTitle": "Privacy title",
50
+ "privacyText": "Privacy text"
51
+ },
52
+ "assetDescriptions": {
53
+ "markLight": "Square icon for compact UI elements on light backgrounds, such as the login and signup screens.",
54
+ "markDark": "Square icon for compact UI elements on dark backgrounds, such as the login and signup screens.",
55
+ "logoLight": "Wide logo for headers and pages with a light background.",
56
+ "logoDark": "Wide logo for headers and pages with a dark background."
57
+ },
58
+ "themes": { "apex": "Apex", "brutal": "Brutal" },
59
+ "colorModes": { "userChoice": "User choice", "lightOnly": "Light only", "darkOnly": "Dark only" },
60
+ "modules": {
61
+ "timesheets": "Timesheets",
62
+ "timesheetsDescription": "Register hours by employee, client, project, and activity.",
63
+ "invoices": "Invoices",
64
+ "invoicesDescription": "Create, manage, send, and track customer invoices.",
65
+ "serviceRequests": "Service requests",
66
+ "serviceRequestsDescription": "Let customers submit requests and follow their progress.",
67
+ "invoiceTimesheets": "Invoice from timesheets",
68
+ "invoiceTimesheetsDescription": "Turn approved timesheet entries into invoice lines; requires Timesheets and Invoices.",
69
+ "preserved": "Data is preserved when this module is disabled."
70
+ },
71
+ "actions": {
72
+ "remove": "Remove",
73
+ "chooseImage": "Choose image",
74
+ "addFeature": "Add feature",
75
+ "back": "Back",
76
+ "save": "Save changes",
77
+ "saveContinue": "Save and continue",
78
+ "complete": "Complete setup"
79
+ },
80
+ "featureTitle": "Feature title",
81
+ "featureDescription": "Feature description",
82
+ "showSupport": "Show support section",
83
+ "review": {
84
+ "portal": "Portal",
85
+ "theme": "Theme",
86
+ "colorMode": "Color mode",
87
+ "activeModules": "Active modules",
88
+ "description": "Completing setup opens the configured public pages and the rest of the portal."
89
+ },
90
+ "messages": {
91
+ "saved": "Settings saved.",
92
+ "reviewSettings": "Review the settings.",
93
+ "saveFailed": "Settings could not be saved.",
94
+ "completeFailed": "Setup could not be completed.",
95
+ "invalidImage": "Use a valid PNG, JPEG, or WebP image smaller than 2 MB.",
96
+ "imageTooLarge": "The image dimensions must not exceed 2400×2400 pixels.",
97
+ "markTooSmall": "Compact icons must be at least 64×64 pixels."
98
+ }
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,105 @@
1
+ {
2
+ "saasSettings": {
3
+ "navigation": "Portaalinstellingen",
4
+ "title": "Portaalinstellingen",
5
+ "onboardingTitle": "Stel je portaal in",
6
+ "public": { "myDashboard": "Mijn dashboard" },
7
+ "bootstrap": {
8
+ "title": "Maak de portaalbeheerder aan",
9
+ "description": "Dit nieuwe portaal is gereserveerd voor de aangewezen beheerder. Maak eerst dit account aan; na het inloggen configureer je het portaal."
10
+ },
11
+ "editor": {
12
+ "setupProgress": "Voortgang van de installatie",
13
+ "portalConfiguration": "Portaalconfiguratie",
14
+ "configurationSections": "Configuratieonderdelen",
15
+ "loading": "Instellingen laden…",
16
+ "unavailable": "Instellingen niet beschikbaar",
17
+ "steps": {
18
+ "branding": "Huisstijl en weergave",
19
+ "modules": "Modules",
20
+ "home": "Homepage",
21
+ "legal": "Juridische pagina's",
22
+ "review": "Controleren"
23
+ },
24
+ "appearanceSection": "Weergave",
25
+ "logoSection": "Logo's voor de gekozen kleurmodus",
26
+ "fields": {
27
+ "portalName": "Portaalnaam",
28
+ "tagline": "Ondertitel",
29
+ "supportEmail": "E-mailadres voor ondersteuning",
30
+ "supportUrl": "URL voor ondersteuning",
31
+ "markLight": "Pictogram voor lichte modus",
32
+ "markDark": "Pictogram voor donkere modus",
33
+ "logoLight": "Volledig logo voor lichte modus",
34
+ "logoDark": "Volledig logo voor donkere modus",
35
+ "theme": "Thema",
36
+ "colorMode": "Kleurmodus",
37
+ "primaryLight": "Primaire kleur — licht",
38
+ "primaryDark": "Primaire kleur — donker",
39
+ "heroTitle": "Titel van de hero",
40
+ "heroDescription": "Beschrijving van de hero",
41
+ "actionLabel": "Tekst van de knop",
42
+ "actionUrl": "URL van de knop",
43
+ "introductionTitle": "Titel van de introductie",
44
+ "introduction": "Introductie",
45
+ "supportTitle": "Titel van ondersteuning",
46
+ "supportText": "Tekst voor ondersteuning",
47
+ "termsTitle": "Titel van de voorwaarden",
48
+ "termsText": "Algemene voorwaarden",
49
+ "privacyTitle": "Titel van het privacybeleid",
50
+ "privacyText": "Privacybeleid"
51
+ },
52
+ "assetDescriptions": {
53
+ "markLight": "Vierkant pictogram voor compacte onderdelen op een lichte achtergrond, zoals de login- en registratieschermen.",
54
+ "markDark": "Vierkant pictogram voor compacte onderdelen op een donkere achtergrond, zoals de login- en registratieschermen.",
55
+ "logoLight": "Breed logo voor kopteksten en pagina's met een lichte achtergrond.",
56
+ "logoDark": "Breed logo voor kopteksten en pagina's met een donkere achtergrond."
57
+ },
58
+ "themes": { "apex": "Apex", "brutal": "Brutal" },
59
+ "colorModes": {
60
+ "userChoice": "Keuze van de gebruiker",
61
+ "lightOnly": "Alleen licht",
62
+ "darkOnly": "Alleen donker"
63
+ },
64
+ "modules": {
65
+ "timesheets": "Urenregistratie",
66
+ "timesheetsDescription": "Registreer uren per medewerker, klant, project en activiteit.",
67
+ "invoices": "Facturen",
68
+ "invoicesDescription": "Maak, beheer, verstuur en volg facturen voor klanten.",
69
+ "serviceRequests": "Serviceverzoeken",
70
+ "serviceRequestsDescription": "Laat klanten verzoeken indienen en de voortgang volgen.",
71
+ "invoiceTimesheets": "Factuur uit urenregistratie",
72
+ "invoiceTimesheetsDescription": "Zet goedgekeurde uren om in factuurregels; vereist Urenregistratie en Facturen.",
73
+ "preserved": "Gegevens blijven bewaard wanneer je deze module uitschakelt."
74
+ },
75
+ "actions": {
76
+ "remove": "Verwijderen",
77
+ "chooseImage": "Afbeelding kiezen",
78
+ "addFeature": "Kenmerk toevoegen",
79
+ "back": "Terug",
80
+ "save": "Wijzigingen opslaan",
81
+ "saveContinue": "Opslaan en doorgaan",
82
+ "complete": "Installatie voltooien"
83
+ },
84
+ "featureTitle": "Titel van het kenmerk",
85
+ "featureDescription": "Beschrijving van het kenmerk",
86
+ "showSupport": "Ondersteuningssectie tonen",
87
+ "review": {
88
+ "portal": "Portaal",
89
+ "theme": "Thema",
90
+ "colorMode": "Kleurmodus",
91
+ "activeModules": "Actieve modules",
92
+ "description": "Na het voltooien van de installatie worden de openbare pagina's en de rest van het portaal geopend."
93
+ },
94
+ "messages": {
95
+ "saved": "Instellingen opgeslagen.",
96
+ "reviewSettings": "Controleer de instellingen.",
97
+ "saveFailed": "De instellingen konden niet worden opgeslagen.",
98
+ "completeFailed": "De installatie kon niet worden voltooid.",
99
+ "invalidImage": "Gebruik een geldige PNG-, JPEG- of WebP-afbeelding kleiner dan 2 MB.",
100
+ "imageTooLarge": "De afmetingen mogen niet groter zijn dan 2400×2400 pixels.",
101
+ "markTooSmall": "Compacte pictogrammen moeten minimaal 64×64 pixels zijn."
102
+ }
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,9 @@
1
+ CREATE SCHEMA IF NOT EXISTS "saas_configuration";
2
+
3
+ CREATE TABLE "saas_configuration"."portal_settings" (
4
+ "id" boolean PRIMARY KEY DEFAULT true NOT NULL CHECK ("id" = true),
5
+ "settings" jsonb NOT NULL,
6
+ "onboarding_step" text DEFAULT 'branding' NOT NULL,
7
+ "completed_at" timestamptz,
8
+ "updated_at" timestamptz DEFAULT now() NOT NULL
9
+ );
package/nuxt.config.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ export default defineNuxtConfig({
4
+ $meta: { name: 'nuxt-customer-portal-saas-configuration' },
5
+ modules: ['@nuxtjs/i18n'],
6
+ css: [fileURLToPath(new URL('./app/assets/css/main.css', import.meta.url))],
7
+ i18n: {
8
+ defaultLocale: 'en',
9
+ strategy: 'no_prefix',
10
+ locales: [
11
+ { code: 'en', file: 'en.json' },
12
+ { code: 'nl', file: 'nl.json' }
13
+ ]
14
+ }
15
+ })
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@nuxt-customer-portal/saas-configuration",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "main": "./nuxt.config.ts",
6
+ "exports": {
7
+ ".": "./nuxt.config.ts",
8
+ "./portal-manifest": "./portal.manifest.mjs",
9
+ "./shared/*": "./shared/*.ts",
10
+ "./server/*": "./server/*.ts"
11
+ },
12
+ "dependencies": {
13
+ "@nuxtjs/i18n": "^10.6.0",
14
+ "drizzle-orm": "^0.45.2",
15
+ "sharp": "^0.34.5",
16
+ "zod": "^4.4.3",
17
+ "@nuxt/ui": "^4.10.0",
18
+ "@nuxt-customer-portal/core": "^0.3.0",
19
+ "@nuxt-customer-portal/ui": "^0.3.0"
20
+ },
21
+ "peerDependencies": {
22
+ "nuxt": "^4.5.1",
23
+ "vue": "^3.5.0"
24
+ },
25
+ "devDependencies": {
26
+ "nuxt": "^4.5.1",
27
+ "vue": "^3.5.0"
28
+ },
29
+ "license": "MIT",
30
+ "homepage": "https://nuxt-customer-portal.com",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/ludulicious/nuxt-customer-portal.git"
34
+ },
35
+ "files": [
36
+ "LICENSE",
37
+ "CHANGELOG.md",
38
+ "README.md",
39
+ "app",
40
+ "server",
41
+ "shared",
42
+ "i18n",
43
+ "migrations",
44
+ "nuxt.config.ts",
45
+ "portal.manifest.mjs"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ }
50
+ }
@@ -0,0 +1,7 @@
1
+ export default {
2
+ id: 'saas-configuration',
3
+ version: '0.3.0',
4
+ source: '@nuxt-customer-portal/saas-configuration',
5
+ dependsOn: ['core'],
6
+ migrations: './migrations'
7
+ }
@@ -0,0 +1,5 @@
1
+ export default defineEventHandler(async (event) => {
2
+ await requirePortalSettingsAdmin(event)
3
+ const body = await readBody(event)
4
+ return completePortalOnboarding(body?.settings)
5
+ })
@@ -0,0 +1,4 @@
1
+ export default defineEventHandler(async (event) => {
2
+ await requirePortalSettingsAdmin(event)
3
+ return readPortalSettings()
4
+ })
@@ -0,0 +1,5 @@
1
+ export default defineEventHandler(async (event) => {
2
+ await requirePortalSettingsAdmin(event)
3
+ const body = await readBody(event)
4
+ return writePortalSettings(body?.settings, body?.step)
5
+ })
@@ -0,0 +1 @@
1
+ export default defineEventHandler(async () => readPortalOnboardingState())
@@ -0,0 +1,10 @@
1
+ export default defineEventHandler(async () => {
2
+ const { settings, completed } = await readPortalSettings()
3
+ return {
4
+ completed,
5
+ branding: settings.branding,
6
+ appearance: settings.appearance,
7
+ enabledModules: settings.enabledModules,
8
+ content: settings.content
9
+ }
10
+ })
@@ -0,0 +1,50 @@
1
+ const alwaysAllowed = ['/api/health', '/api/portal/bootstrap', '/api/portal/public']
2
+ const moduleApiPrefixes: Record<string, string[]> = {
3
+ timesheets: ['/api/timesheets'],
4
+ invoices: ['/api/invoices'],
5
+ 'service-requests': ['/api/service-requests'],
6
+ 'invoice-timesheets': ['/api/invoice-timesheets']
7
+ }
8
+
9
+ export default defineEventHandler(async (event) => {
10
+ const path = getRequestURL(event).pathname
11
+ if (!path.startsWith('/api/')) {
12
+ return
13
+ }
14
+ if (alwaysAllowed.some((prefix) => path.startsWith(prefix))) {
15
+ return
16
+ }
17
+
18
+ const state = await readPortalOnboardingState()
19
+ if (path.startsWith('/api/auth/')) {
20
+ if (!state.completed && path.includes('/sign-in/social')) {
21
+ throw createError({
22
+ statusCode: 403,
23
+ message: 'Social authentication is unavailable until portal setup is complete'
24
+ })
25
+ }
26
+ if (!state.completed && path.includes('/sign-up/')) {
27
+ const body = await readBody(event)
28
+ if (!isReservedPortalAdmin(body?.email)) {
29
+ throw createError({
30
+ statusCode: 403,
31
+ message: 'Only the reserved portal administrator can create the first account'
32
+ })
33
+ }
34
+ }
35
+ return
36
+ }
37
+
38
+ if (!state.completed && !path.startsWith('/api/admin/portal-settings')) {
39
+ throw createError({ statusCode: 503, message: 'Portal setup is not complete' })
40
+ }
41
+
42
+ if (state.completed) {
43
+ const { settings } = await readPortalSettings()
44
+ for (const [moduleId, prefixes] of Object.entries(moduleApiPrefixes)) {
45
+ if (!settings.enabledModules.includes(moduleId as never) && prefixes.some((prefix) => path.startsWith(prefix))) {
46
+ throw createError({ statusCode: 404, message: 'Module is not enabled' })
47
+ }
48
+ }
49
+ }
50
+ })
@@ -0,0 +1,138 @@
1
+ import type { H3Event } from 'h3'
2
+ import sharp from 'sharp'
3
+ import { getSession } from '@nuxt-customer-portal/core/server'
4
+ import { pool } from '@nuxt-customer-portal/core/server/utils/db'
5
+ import {
6
+ defaultPortalSettings,
7
+ portalOnboardingSteps,
8
+ portalSettingsSchema,
9
+ type PortalOnboardingState,
10
+ type PortalOnboardingStep,
11
+ type PortalSettings
12
+ } from '../../shared/settings'
13
+
14
+ interface SettingsRow {
15
+ settings: PortalSettings
16
+ onboarding_step: string
17
+ completed_at: Date | null
18
+ }
19
+
20
+ const reservedAdminEmails = () =>
21
+ new Set(
22
+ (process.env.ADMIN_EMAILS || '')
23
+ .split(',')
24
+ .map((value) => value.trim().toLowerCase())
25
+ .filter(Boolean)
26
+ )
27
+
28
+ export async function readPortalSettings(): Promise<{
29
+ settings: PortalSettings
30
+ step: PortalOnboardingStep
31
+ completed: boolean
32
+ }> {
33
+ const result = await pool.query<SettingsRow>(
34
+ 'SELECT settings, onboarding_step, completed_at FROM saas_configuration.portal_settings WHERE id=true'
35
+ )
36
+ const row = result.rows[0]
37
+ if (row) {
38
+ const step =
39
+ row.onboarding_step === 'appearance'
40
+ ? 'branding'
41
+ : portalOnboardingSteps.includes(row.onboarding_step as PortalOnboardingStep)
42
+ ? (row.onboarding_step as PortalOnboardingStep)
43
+ : 'branding'
44
+ return { settings: portalSettingsSchema.parse(row.settings), step, completed: Boolean(row.completed_at) }
45
+ }
46
+ const defaults = defaultPortalSettings(process.env.PORTAL_PROVIDER_NAME || 'Customer Portal')
47
+ await pool.query(
48
+ `INSERT INTO saas_configuration.portal_settings (id, settings) VALUES (true, $1::jsonb) ON CONFLICT (id) DO NOTHING`,
49
+ [JSON.stringify(defaults)]
50
+ )
51
+ return { settings: defaults, step: 'branding', completed: false }
52
+ }
53
+
54
+ export async function readPortalOnboardingState(): Promise<PortalOnboardingState> {
55
+ const [admin, stored] = await Promise.all([
56
+ pool.query<{ exists: boolean }>(`SELECT EXISTS(SELECT 1 FROM "user" WHERE role='admin') AS exists`),
57
+ readPortalSettings()
58
+ ])
59
+ return { adminExists: Boolean(admin.rows[0]?.exists), completed: stored.completed, step: stored.step }
60
+ }
61
+
62
+ export async function requirePortalSettingsAdmin(event: H3Event) {
63
+ const session = await getSession(event)
64
+ if (!session?.user || session.user.role !== 'admin') {
65
+ throw createError({ statusCode: 403, message: 'System administrator access required' })
66
+ }
67
+ return session
68
+ }
69
+
70
+ export async function validatePortalBrandImages(settings: PortalSettings) {
71
+ for (const [name, value] of Object.entries(settings.branding)) {
72
+ if (!name.toLowerCase().includes('logo') && !name.toLowerCase().includes('mark')) {
73
+ continue
74
+ }
75
+ if (!value) {
76
+ continue
77
+ }
78
+ const encoded = String(value).split(',', 2)[1] || ''
79
+ const buffer = Buffer.from(encoded, 'base64')
80
+ if (buffer.length > 2_000_000) {
81
+ throw createError({ statusCode: 400, message: `${name} must be smaller than 2 MB` })
82
+ }
83
+ try {
84
+ const metadata = await sharp(buffer).metadata()
85
+ if (!['png', 'jpeg', 'webp'].includes(metadata.format || '') || !metadata.width || !metadata.height) {
86
+ throw new Error('Unsupported image')
87
+ }
88
+ if (metadata.width > 2400 || metadata.height > 2400) {
89
+ throw createError({ statusCode: 400, message: `${name} dimensions must not exceed 2400×2400` })
90
+ }
91
+ if (name.startsWith('mark') && (metadata.width < 64 || metadata.height < 64)) {
92
+ throw createError({ statusCode: 400, message: `${name} must be at least 64×64` })
93
+ }
94
+ } catch (error) {
95
+ if (error && typeof error === 'object' && 'statusCode' in error) {
96
+ throw error
97
+ }
98
+ throw createError({ statusCode: 400, message: `${name} is not a valid image` })
99
+ }
100
+ }
101
+ }
102
+
103
+ export async function writePortalSettings(input: unknown, requestedStep?: unknown) {
104
+ const settings = portalSettingsSchema.parse(input)
105
+ await validatePortalBrandImages(settings)
106
+ const step = portalOnboardingSteps.includes(requestedStep as PortalOnboardingStep)
107
+ ? (requestedStep as PortalOnboardingStep)
108
+ : undefined
109
+ await pool.query(
110
+ `UPDATE saas_configuration.portal_settings SET settings=$1::jsonb, onboarding_step=COALESCE($2,onboarding_step), updated_at=now() WHERE id=true`,
111
+ [JSON.stringify(settings), step || null]
112
+ )
113
+ return { settings, step: step || (await readPortalSettings()).step }
114
+ }
115
+
116
+ export async function completePortalOnboarding(input: unknown) {
117
+ const settings = portalSettingsSchema.parse(input)
118
+ await validatePortalBrandImages(settings)
119
+ const client = await pool.connect()
120
+ try {
121
+ await client.query('BEGIN')
122
+ await client.query(
123
+ `UPDATE saas_configuration.portal_settings SET settings=$1::jsonb, onboarding_step='review', completed_at=now(), updated_at=now() WHERE id=true`,
124
+ [JSON.stringify(settings)]
125
+ )
126
+ await client.query('COMMIT')
127
+ } catch (error) {
128
+ await client.query('ROLLBACK')
129
+ throw error
130
+ } finally {
131
+ client.release()
132
+ }
133
+ return { settings, step: 'review' as const, completed: true }
134
+ }
135
+
136
+ export function isReservedPortalAdmin(email: unknown) {
137
+ return typeof email === 'string' && reservedAdminEmails().has(email.trim().toLowerCase())
138
+ }
@@ -0,0 +1,30 @@
1
+ import type { PortalFeatureDefinition } from '@nuxt-customer-portal/core/shared/types/feature'
2
+
3
+ export const saasConfigurationFeature: PortalFeatureDefinition = {
4
+ id: 'saas-configuration',
5
+ navigation: [
6
+ {
7
+ id: 'portal-settings',
8
+ labelKey: 'saasSettings.navigation',
9
+ icon: 'i-lucide-palette',
10
+ to: '/admin/portal-settings',
11
+ audiences: ['admin'],
12
+ location: 'admin',
13
+ order: 120
14
+ }
15
+ ],
16
+ moduleMenuItems: [
17
+ {
18
+ moduleId: 'admin',
19
+ item: {
20
+ id: 'portal-settings',
21
+ labelKey: 'saasSettings.navigation',
22
+ icon: 'i-lucide-palette',
23
+ to: '/admin/portal-settings',
24
+ audiences: ['admin'],
25
+ order: 120
26
+ }
27
+ }
28
+ ],
29
+ policy: { owner: [], admin: [], member: [] }
30
+ }
@@ -0,0 +1,12 @@
1
+ /** Choose the foreground with the highest contrast against a validated sRGB hex color. */
2
+ export const primaryForeground = (hex: string): '#ffffff' | '#000000' => {
3
+ if (!/^#[0-9a-f]{6}$/i.test(hex)) {
4
+ return '#ffffff'
5
+ }
6
+ const channels = [1, 3, 5].map((offset) => {
7
+ const value = parseInt(hex.slice(offset, offset + 2), 16) / 255
8
+ return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4
9
+ })
10
+ const luminance = channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722
11
+ return 1.05 / (luminance + 0.05) >= (luminance + 0.05) / 0.05 ? '#ffffff' : '#000000'
12
+ }