chore(portal,operator): upgrade to Nuxt 4

Upgrade both Nuxt apps to Nuxt 4.4.6 (vue-tsc 3, TypeScript 5.6, undici 7) and add a root tsconfig.json to each app. Fix the strict-null / noUncheckedIndexedAccess errors surfaced by Nuxt 4's stricter generated tsconfig and vue-tsc 3. Drop the nuxt-oidc-auth pnpm patch (Nuxt 4 fixes the prepare:types crash natively).
This commit is contained in:
Ronni Baslund
2026-05-30 08:02:43 +02:00
parent 0bd4e5498e
commit 17ffd95a70
25 changed files with 888 additions and 706 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ const initials = computed(() =>
.split(' ') .split(' ')
.filter(Boolean) .filter(Boolean)
.slice(0, 2) .slice(0, 2)
.map((p) => p[0].toUpperCase()) .map((p) => p.charAt(0).toUpperCase())
.join(''), .join(''),
) )
+4 -1
View File
@@ -10,7 +10,10 @@ const route = useRoute()
const open = ref(false) const open = ref(false)
const displayName = computed(() => user.value?.userInfo?.name || user.value?.userName || 'operator') const displayName = computed<string>(() => {
const name = (user.value?.userInfo as { name?: string } | undefined)?.name
return name || (user.value?.userName as string | undefined) || 'operator'
})
const email = computed(() => (user.value?.userInfo as { email?: string } | undefined)?.email ?? '') const email = computed(() => (user.value?.userInfo as { email?: string } | undefined)?.email ?? '')
function toggle() { function toggle() {
+5 -3
View File
@@ -2,7 +2,7 @@
"name": "@dezky/operator", "name": "@dezky/operator",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"description": "Dezky operator portal — internal admin app (Nuxt 3)", "description": "Dezky operator portal — internal admin app (Nuxt 4)",
"scripts": { "scripts": {
"dev": "nuxt dev --host 0.0.0.0 --port 3000", "dev": "nuxt dev --host 0.0.0.0 --port 3000",
"build": "nuxt build", "build": "nuxt build",
@@ -11,14 +11,16 @@
"lint": "eslint ." "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"nuxt": "^3.13.0", "nuxt": "^4.4.6",
"nuxt-oidc-auth": "1.0.0-beta.11", "nuxt-oidc-auth": "1.0.0-beta.11",
"undici": "^7.2.1",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"typescript": "^5.5.0" "typescript": "^5.6.0",
"vue-tsc": "^3.2.6"
}, },
"packageManager": "pnpm@9.12.0" "packageManager": "pnpm@9.12.0"
} }
+1 -1
View File
@@ -391,7 +391,7 @@ function fmtRelative(iso: string | null | undefined): string {
<div class="cap"> <div class="cap">
{{ {{
archives?.length archives?.length
? `archived through seq ${archives[0].endSeq} · ${archives.length} batch${archives.length === 1 ? '' : 'es'}` ? `archived through seq ${archives[0]!.endSeq} · ${archives.length} batch${archives.length === 1 ? '' : 'es'}`
: 'no archives yet · 90-day hot retention' : 'no archives yet · 90-day hot retention'
}} }}
</div> </div>
+1 -1
View File
@@ -209,7 +209,7 @@ const sortedPrices = computed<PriceRow[]>(() =>
<td v-for="c in CURRENCIES" :key="c" class="cell-amount"> <td v-for="c in CURRENCIES" :key="c" class="cell-amount">
<template v-if="drafts[row._id]"> <template v-if="drafts[row._id]">
<input <input
v-model="drafts[row._id][c]" v-model="drafts[row._id]![c]"
type="text" type="text"
inputmode="decimal" inputmode="decimal"
class="amount-input" class="amount-input"
+407 -331
View File
File diff suppressed because it is too large Load Diff
@@ -9,7 +9,7 @@ import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
function decodeJwtClaims(token: string): Record<string, unknown> { function decodeJwtClaims(token: string): Record<string, unknown> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length < 2) throw new Error('Not a JWT') if (parts.length < 2) throw new Error('Not a JWT')
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') const payload = parts[1]!.replace(/-/g, '+').replace(/_/g, '/')
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4) const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4)
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
} }
+5 -1
View File
@@ -26,7 +26,11 @@ function originatingIp(event: H3Event): string | undefined {
export async function platformApi<T = unknown>( export async function platformApi<T = unknown>(
event: H3Event, event: H3Event,
path: string, path: string,
init: { method?: string; body?: unknown; query?: Record<string, string | number | undefined> } = {}, init: {
method?: string
body?: BodyInit | Record<string, unknown> | null
query?: Record<string, string | number | undefined>
} = {},
): Promise<T> { ): Promise<T> {
const session = await getUserSession(event).catch(() => null) const session = await getUserSession(event).catch(() => null)
const accessToken = (session as { accessToken?: string } | null)?.accessToken const accessToken = (session as { accessToken?: string } | null)?.accessToken
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
+1 -1
View File
@@ -11,7 +11,7 @@ const initials = computed(() =>
.split(' ') .split(' ')
.filter(Boolean) .filter(Boolean)
.slice(0, 2) .slice(0, 2)
.map((p) => p[0].toUpperCase()) .map((p) => p.charAt(0).toUpperCase())
.join(''), .join(''),
) )
+3 -2
View File
@@ -18,9 +18,10 @@ try { oidc = useOidcAuth() } catch { oidc = null }
const open = ref(false) const open = ref(false)
const rootRef = ref<HTMLElement | null>(null) const rootRef = ref<HTMLElement | null>(null)
const displayName = computed(() => { const displayName = computed<string>(() => {
const u = oidc?.user?.value const u = oidc?.user?.value
return u?.userInfo?.name || u?.userName || 'Anne Hansen' const name = (u?.userInfo as { name?: string } | undefined)?.name
return name || (u?.userName as string | undefined) || 'Anne Hansen'
}) })
const email = computed(() => { const email = computed(() => {
const u = oidc?.user?.value const u = oidc?.user?.value
@@ -1,20 +1,17 @@
<script setup lang="ts"> <script setup lang="ts" generic="T extends { id: string; current?: boolean; trusted?: boolean }">
// "..." menu for a single device row. The menu is teleported to <body> so it // "..." menu for a single device row. The menu is teleported to <body> so it
// escapes any overflow/clip on the table — same pattern as the React design // escapes any overflow/clip on the table — same pattern as the React design
// source. Closes on outside-click, Escape, or scroll. // source. Closes on outside-click, Escape, or scroll.
//
// Generic over the device type so the emitted payload keeps the caller's full
// device shape (label, os, …) instead of narrowing to a subset.
interface DeviceLike { defineProps<{ device: T }>()
id: string
current?: boolean
trusted?: boolean
}
const props = defineProps<{ device: DeviceLike }>()
const emit = defineEmits<{ const emit = defineEmits<{
rename: [DeviceLike] rename: [T]
trust: [DeviceLike] trust: [T]
history: [DeviceLike] history: [T]
revoke: [DeviceLike] revoke: [T]
}>() }>()
const open = ref(false) const open = ref(false)
@@ -24,7 +24,7 @@ const emit = defineEmits<{ 'update:modelValue': [Presence] }>()
const open = ref(false) const open = ref(false)
const rootRef = ref<HTMLElement | null>(null) const rootRef = ref<HTMLElement | null>(null)
const current = computed(() => opts.find((o) => o.value === props.modelValue) ?? opts[0]) const current = computed(() => opts.find((o) => o.value === props.modelValue) ?? opts[0]!)
function pick(v: Presence) { function pick(v: Presence) {
emit('update:modelValue', v) emit('update:modelValue', v)
+2 -1
View File
@@ -36,7 +36,8 @@ const geometry = computed(() => {
}) })
const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ') const line = pts.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(1)} ${y.toFixed(1)}`).join(' ')
const area = `${line} L ${props.width} ${props.height} L 0 ${props.height} Z` const area = `${line} L ${props.width} ${props.height} L 0 ${props.height} Z`
const last = { x: pts[pts.length - 1][0], y: pts[pts.length - 1][1] } const lastPt = pts[pts.length - 1]!
const last = { x: lastPt[0], y: lastPt[1] }
return { line, area, last, min, max } return { line, area, last, min, max }
}) })
</script> </script>
+5 -3
View File
@@ -2,7 +2,7 @@
"name": "@dezky/portal", "name": "@dezky/portal",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"description": "Dezky customer-facing portal — Nuxt 3", "description": "Dezky customer-facing portal — Nuxt 4",
"scripts": { "scripts": {
"dev": "nuxt dev --host 0.0.0.0 --port 3000 --dotenv ../../.env", "dev": "nuxt dev --host 0.0.0.0 --port 3000 --dotenv ../../.env",
"build": "nuxt build", "build": "nuxt build",
@@ -11,14 +11,16 @@
"lint": "eslint ." "lint": "eslint ."
}, },
"dependencies": { "dependencies": {
"nuxt": "^3.13.0", "nuxt": "^4.4.6",
"nuxt-oidc-auth": "1.0.0-beta.11", "nuxt-oidc-auth": "1.0.0-beta.11",
"undici": "^7.2.1",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"typescript": "^5.5.0" "typescript": "^5.6.0",
"vue-tsc": "^3.2.6"
}, },
"packageManager": "pnpm@9.12.0" "packageManager": "pnpm@9.12.0"
} }
+2 -2
View File
@@ -302,9 +302,9 @@ function confirmPause() {
<div class="lead">Pick a new tier. We'll prorate the difference and apply it on your next invoice.</div> <div class="lead">Pick a new tier. We'll prorate the difference and apply it on your next invoice.</div>
<div class="plan-options"> <div class="plan-options">
<button v-for="p in [ <button v-for="p in [
{ id: 'basic', name: 'Basic', price: '49 DKK / seat / mo', d: 'Mail · Drev · 50 GB' }, { id: 'basic', name: 'Basic', price: '49 DKK / seat / mo', d: 'Mail · Drev · 50 GB', current: false },
{ id: 'business', name: 'Business · current', price: '78 DKK / seat / mo', d: 'Everything in Basic + Møder + Chat · 200 GB', current: true }, { id: 'business', name: 'Business · current', price: '78 DKK / seat / mo', d: 'Everything in Basic + Møder + Chat · 200 GB', current: true },
{ id: 'enterprise', name: 'Enterprise', price: 'from 140 DKK / seat / mo', d: 'SSO contracts · audit log retention · 1 TB' }, { id: 'enterprise', name: 'Enterprise', price: 'from 140 DKK / seat / mo', d: 'SSO contracts · audit log retention · 1 TB', current: false },
]" :key="p.id" :class="['plan-card', { active: p.current }]"> ]" :key="p.id" :class="['plan-card', { active: p.current }]">
<div class="plan-name">{{ p.name }}</div> <div class="plan-name">{{ p.name }}</div>
<Mono dim>{{ p.price }}</Mono> <Mono dim>{{ p.price }}</Mono>
+5 -1
View File
@@ -18,6 +18,10 @@ const editTemplate = ref<typeof TEMPLATES[number] | null>(null)
const subject = ref('') const subject = ref('')
const body = ref('') const body = ref('')
const testSent = ref(false) const testSent = ref(false)
function sendTest() {
testSent.value = true
setTimeout(() => (testSent.value = false), 2500)
}
const publishOpen = ref(false) const publishOpen = ref(false)
const publishState = ref<'confirm' | 'publishing' | 'done'>('confirm') const publishState = ref<'confirm' | 'publishing' | 'done'>('confirm')
@@ -384,7 +388,7 @@ const renderedBody = computed(() =>
Reset to default Reset to default
</UiButton> </UiButton>
<div style="flex: 1" /> <div style="flex: 1" />
<UiButton variant="secondary" @click="testSent = true; setTimeout(() => testSent = false, 2500)"> <UiButton variant="secondary" @click="sendTest">
<template #leading><UiIcon name="mail" :size="13" /></template> <template #leading><UiIcon name="mail" :size="13" /></template>
{{ testSent ? 'Sent to anne@dezky.com ✓' : 'Send test to me' }} {{ testSent ? 'Sent to anne@dezky.com ✓' : 'Send test to me' }}
</UiButton> </UiButton>
+12 -6
View File
@@ -17,12 +17,18 @@ const inviteStep = ref(1)
const seatsOpen = ref(false) const seatsOpen = ref(false)
const seatsExtra = ref(5) const seatsExtra = ref(5)
const stats = [ const stats: Array<{
{ label: 'Seats used', value: '11 / 25', delta: '+2 this week', deltaTone: 'up' as const, hint: '' }, label: string
{ label: 'Storage', value: '1.4 TB', delta: '64% of 2.2 TB', hint: '' }, value: string
{ label: 'Mail flow', value: 'Healthy', hint: '99.98% · last 7d' }, delta?: string
{ label: 'Monthly spend', value: '1.940 DKK', hint: 'next invoice 01 Jun' }, deltaTone?: 'up' | 'down'
] as const hint: string
}> = [
{ label: 'Seats used', value: '11 / 25', delta: '+2 this week', deltaTone: 'up', hint: '' },
{ label: 'Storage', value: '1.4 TB', delta: '64% of 2.2 TB', hint: '' },
{ label: 'Mail flow', value: 'Healthy', hint: '99.98% · last 7d' },
{ label: 'Monthly spend', value: '1.940 DKK', hint: 'next invoice 01 Jun' },
]
const recent = sampleAudit.slice(0, 6) const recent = sampleAudit.slice(0, 6)
+1 -1
View File
@@ -605,7 +605,7 @@ Mikkel Sørensen,mikkel@baslund.dk,admin,Engineering,business</pre>
<label v-for="r in ['member', 'admin', 'owner'] as const" :key="r" class="role-row" :class="{ active: roleChoice === r }"> <label v-for="r in ['member', 'admin', 'owner'] as const" :key="r" class="role-row" :class="{ active: roleChoice === r }">
<input type="radio" :value="r" v-model="roleChoice" /> <input type="radio" :value="r" v-model="roleChoice" />
<div> <div>
<div class="role-name">{{ r[0].toUpperCase() + r.slice(1) }}</div> <div class="role-name">{{ r.charAt(0).toUpperCase() + r.slice(1) }}</div>
<Mono dim> <Mono dim>
{{ r === 'member' ? 'Standard access to apps' : {{ r === 'member' ? 'Standard access to apps' :
r === 'admin' ? 'Manage users, billing, and settings' : r === 'admin' ? 'Manage users, billing, and settings' :
+1 -1
View File
@@ -59,7 +59,7 @@ function tenantColor(slug?: string): string {
if (!slug) return 'var(--text-mute)' if (!slug) return 'var(--text-mute)'
let h = 0 let h = 0
for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) | 0 for (let i = 0; i < slug.length; i++) h = (h * 31 + slug.charCodeAt(i)) | 0
return PALETTE[Math.abs(h) % PALETTE.length] return PALETTE[Math.abs(h) % PALETTE.length]!
} }
function eventTone(e: ActivityEvent): AuditRow['tone'] { function eventTone(e: ActivityEvent): AuditRow['tone'] {
+407 -331
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ import { getUserSession } from 'nuxt-oidc-auth/runtime/server/utils/session.js'
function decodeJwtClaims(token: string): Record<string, unknown> { function decodeJwtClaims(token: string): Record<string, unknown> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length < 2) throw new Error('Not a JWT') if (parts.length < 2) throw new Error('Not a JWT')
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') const payload = parts[1]!.replace(/-/g, '+').replace(/_/g, '/')
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4) const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4)
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
} }
@@ -17,7 +17,7 @@ const ADMIN_GROUPS = new Set(['dezky-platform-admins', 'authentik Admins'])
function decodeJwtClaims(token: string): Record<string, unknown> { function decodeJwtClaims(token: string): Record<string, unknown> {
const parts = token.split('.') const parts = token.split('.')
if (parts.length < 2) throw new Error('Not a JWT') if (parts.length < 2) throw new Error('Not a JWT')
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/') const payload = parts[1]!.replace(/-/g, '+').replace(/_/g, '/')
const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4) const padded = payload + '='.repeat((4 - (payload.length % 4)) % 4)
return JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) return JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
} }
+6
View File
@@ -0,0 +1,6 @@
{
// Root tsconfig for `nuxt typecheck` / IDEs. Extends the config Nuxt
// generates into .nuxt/ on `nuxt prepare` (aliases, auto-imports, etc.).
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}
+3 -2
View File
@@ -209,9 +209,10 @@ function onKey(e: KeyboardEvent) {
activeIndex.value = Math.max(activeIndex.value - 1, 0) activeIndex.value = Math.max(activeIndex.value - 1, 0)
scrollActiveIntoView() scrollActiveIntoView()
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
if (activeIndex.value >= 0 && filtered.value[activeIndex.value]) { const sel = filtered.value[activeIndex.value]
if (activeIndex.value >= 0 && sel) {
e.preventDefault() e.preventDefault()
selectCountry(filtered.value[activeIndex.value]) selectCountry(sel)
} }
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
open.value = false open.value = false