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:
@@ -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(''),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Generated
+407
-331
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'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
||||||
@@ -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(''),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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' :
|
||||||
|
|||||||
@@ -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'] {
|
||||||
|
|||||||
Generated
+407
-331
File diff suppressed because it is too large
Load Diff
@@ -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'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user