Frontend: realm admin UI, passkey realm badges, cross-realm notices
- Admin: replace Server Options dialog with per-realm management — realms table on the overview, add/edit/delete realm dialog backed by /auth/api/admin/realms/. Origins may be any well-formed origin; non-subdomain ones are related origins (ROR, max 5) and the dialog points at the .well-known/webauthn URL that must list them. Connectivity checks compare against the edited realm's rp-id and degrade to warnings instead of blocking saves. - Host mode (limited profile) now keys off own_auth_host so realms sharing another realm's auth host serve the full profile locally. - Credential list shows a realm badge on passkeys registered for a different rp-id than the current realm. - Profile shows an enrollment prompt when the user has no passkey for the current realm (e.g. after a cross-realm remote login). - Remote auth permit shows the requesting realm when it differs from the approver's own. - settings cache can be force-refreshed after realm changes.
This commit is contained in:
@@ -37,11 +37,13 @@ function normalizeHost(raw) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host mode is active when an auth_host is configured AND the current host differs from it.
|
* Host mode is active when an own_auth_host is configured AND the current host differs from it.
|
||||||
* In host mode, we show a limited profile view with logout and link to full profile.
|
* In host mode, we show a limited profile view with logout and link to full profile.
|
||||||
|
* own_auth_host (not auth_host) is used so that realms sharing another realm's auth host
|
||||||
|
* still serve the full profile on their own hosts.
|
||||||
*/
|
*/
|
||||||
const isHostMode = computed(() => {
|
const isHostMode = computed(() => {
|
||||||
const authHost = store.settings?.auth_host
|
const authHost = store.settings?.own_auth_host
|
||||||
if (!authHost) return false
|
if (!authHost) return false
|
||||||
const currentHost = normalizeHost(window.location.host)
|
const currentHost = normalizeHost(window.location.host)
|
||||||
const configuredHost = normalizeHost(authHost)
|
const configuredHost = normalizeHost(authHost)
|
||||||
@@ -99,7 +101,7 @@ onMounted(async () => {
|
|||||||
if (rpName) {
|
if (rpName) {
|
||||||
// In host mode, show "account summary" style title
|
// In host mode, show "account summary" style title
|
||||||
// Settings are loaded but isHostMode depends on them, so check here
|
// Settings are loaded but isHostMode depends on them, so check here
|
||||||
const authHost = store.settings?.auth_host
|
const authHost = store.settings?.own_auth_host
|
||||||
const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost)
|
const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost)
|
||||||
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
document.title = inHostMode ? `${rpName} · Account summary` : rpName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const error = ref(null)
|
|||||||
const orgs = ref([])
|
const orgs = ref([])
|
||||||
const permissions = ref([])
|
const permissions = ref([])
|
||||||
const oidcClients = ref([])
|
const oidcClients = ref([])
|
||||||
|
const realms = ref([])
|
||||||
const currentOrgId = ref(null) // UUID of selected org for detail view
|
const currentOrgId = ref(null) // UUID of selected org for detail view
|
||||||
const currentUserId = ref(null) // UUID for user detail view
|
const currentUserId = ref(null) // UUID for user detail view
|
||||||
const currentOidcId = ref(null) // UUID for OIDC client detail view
|
const currentOidcId = ref(null) // UUID for OIDC client detail view
|
||||||
@@ -174,6 +175,16 @@ async function loadAdminData() {
|
|||||||
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
|
oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Realm list is master-admin only; callers guard on isMasterAdmin
|
||||||
|
async function loadRealms() {
|
||||||
|
try {
|
||||||
|
realms.value = await apiJson('/auth/api/admin/realms/')
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Unable to load realms', e)
|
||||||
|
realms.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get users for a role as sorted array of [uuid, user]
|
// Helper to get users for a role as sorted array of [uuid, user]
|
||||||
function roleUsers(org, roleUuid) {
|
function roleUsers(org, roleUuid) {
|
||||||
return Object.entries(org.users)
|
return Object.entries(org.users)
|
||||||
@@ -207,6 +218,7 @@ function clearSensitiveState() {
|
|||||||
orgs.value = []
|
orgs.value = []
|
||||||
permissions.value = []
|
permissions.value = []
|
||||||
oidcClients.value = []
|
oidcClients.value = []
|
||||||
|
realms.value = []
|
||||||
userDetail.value = null
|
userDetail.value = null
|
||||||
editingOidcClient.value = null
|
editingOidcClient.value = null
|
||||||
authenticated.value = false
|
authenticated.value = false
|
||||||
@@ -236,6 +248,7 @@ async function load() {
|
|||||||
await loadAdminData()
|
await loadAdminData()
|
||||||
// If we get here, user has admin access - now fetch user info for display
|
// If we get here, user has admin access - now fetch user info for display
|
||||||
await loadUserInfo()
|
await loadUserInfo()
|
||||||
|
if (isMasterAdmin.value) await loadRealms()
|
||||||
|
|
||||||
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
|
if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) {
|
||||||
if (!window.location.hash || window.location.hash === '#overview') {
|
if (!window.location.hash || window.location.hash === '#overview') {
|
||||||
@@ -462,21 +475,41 @@ function createPermissionForClient(clientId) {
|
|||||||
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
|
openDialog('perm-create', { display_name: '', scope: '', domain: clientId })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openServerConfig() {
|
function createRealm() {
|
||||||
try {
|
openDialog('realm-edit', {
|
||||||
const config = await apiJson('/auth/api/admin/server-config')
|
isNew: true,
|
||||||
// Strip https:// scheme from stored origins and auth_host for editing
|
rp_id: '',
|
||||||
const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, ''))
|
rp_name: '',
|
||||||
const auth_host = (config.auth_host || '').replace(/^https:\/\//, '')
|
auth_host: '',
|
||||||
openDialog('server-config', {
|
origins: [],
|
||||||
rp_name: config.rp_name || '',
|
originValidation: [],
|
||||||
auth_host,
|
authHostValidation: null,
|
||||||
origins,
|
})
|
||||||
originValidation: origins.map(() => null),
|
}
|
||||||
})
|
|
||||||
} catch (e) {
|
function openRealm(realm) {
|
||||||
authStore.showMessage(e.message || 'Failed to load server configuration', 'error')
|
// Strip https:// scheme from stored origins and auth_host for editing
|
||||||
}
|
const origins = (realm.origins || []).map(o => o.replace(/^https:\/\//, ''))
|
||||||
|
openDialog('realm-edit', {
|
||||||
|
isNew: false,
|
||||||
|
rp_id: realm.rp_id,
|
||||||
|
rp_name: realm.rp_name || '',
|
||||||
|
auth_host: (realm.auth_host || '').replace(/^https:\/\//, ''),
|
||||||
|
origins,
|
||||||
|
originValidation: origins.map(() => null),
|
||||||
|
authHostValidation: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRealm(realm) {
|
||||||
|
openDialog('confirm', {
|
||||||
|
message: `Delete realm "${realm.rp_id}"? This is refused while any passkeys remain registered for it.`,
|
||||||
|
action: async () => {
|
||||||
|
await apiJson(`/auth/api/admin/realms/${realm.rp_id}`, { method: 'DELETE' })
|
||||||
|
authStore.showMessage(`Realm "${realm.rp_id}" deleted.`, 'success', 2500)
|
||||||
|
await loadRealms()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteOidcClient(client) {
|
function deleteOidcClient(client) {
|
||||||
@@ -900,25 +933,32 @@ async function submitDialog() {
|
|||||||
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error')
|
||||||
})
|
})
|
||||||
return // Don't call closeDialog() again
|
return // Don't call closeDialog() again
|
||||||
} else if (t === 'server-config') {
|
} else if (t === 'realm-edit') {
|
||||||
const rp_name = dialog.value.data.rp_name?.trim() || ''
|
const d = dialog.value.data
|
||||||
const auth_host = dialog.value.data.auth_host?.trim() || ''
|
const rp_id = d.rp_id?.trim().toLowerCase()
|
||||||
|
if (!rp_id) throw new Error('RP ID (domain) required')
|
||||||
|
const rp_name = d.rp_name?.trim() || ''
|
||||||
|
const auth_host = d.auth_host?.trim() || ''
|
||||||
// Origins are stored as-is (hostnames); backend normalizes with https://
|
// Origins are stored as-is (hostnames); backend normalizes with https://
|
||||||
const origins = dialog.value.data.origins
|
const origins = (d.origins || [])
|
||||||
.map(o => o.trim())
|
.map(o => o.trim())
|
||||||
.filter(o => o)
|
.filter(o => o)
|
||||||
|
|
||||||
closeDialog()
|
closeDialog()
|
||||||
apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } })
|
const req = d.isNew
|
||||||
|
? apiJson('/auth/api/admin/realms/', { method: 'POST', body: { rp_id, rp_name, auth_host, origins } })
|
||||||
|
: apiJson(`/auth/api/admin/realms/${rp_id}`, { method: 'PATCH', body: { rp_name, auth_host, origins } })
|
||||||
|
req
|
||||||
.then(() => {
|
.then(() => {
|
||||||
authStore.showMessage('Server configuration updated.', 'success', 2500)
|
authStore.showMessage(`Realm "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
|
||||||
|
loadRealms()
|
||||||
// Reload settings to reflect rp_name changes
|
// Reload settings to reflect rp_name changes
|
||||||
authStore.loadSettings().then(() => {
|
authStore.loadSettings(true).then(() => {
|
||||||
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
authStore.showMessage(e.message || 'Failed to update server configuration', 'error')
|
authStore.showMessage(e.message || 'Failed to save realm', 'error')
|
||||||
})
|
})
|
||||||
return // Don't call closeDialog() again
|
return // Don't call closeDialog() again
|
||||||
} else if (t === 'confirm') {
|
} else if (t === 'confirm') {
|
||||||
@@ -973,6 +1013,7 @@ async function submitDialog() {
|
|||||||
:orgs="orgs"
|
:orgs="orgs"
|
||||||
:permissions="permissions"
|
:permissions="permissions"
|
||||||
:oidc-clients="oidcClients"
|
:oidc-clients="oidcClients"
|
||||||
|
:realms="realms"
|
||||||
:navigation-disabled="hasActiveModal"
|
:navigation-disabled="hasActiveModal"
|
||||||
:permission-summary="permissionSummary"
|
:permission-summary="permissionSummary"
|
||||||
@create-org="createOrg"
|
@create-org="createOrg"
|
||||||
@@ -986,7 +1027,9 @@ async function submitDialog() {
|
|||||||
@create-oidc-client="createOidcClient"
|
@create-oidc-client="createOidcClient"
|
||||||
@open-oidc-client="openOidcClient"
|
@open-oidc-client="openOidcClient"
|
||||||
@delete-oidc-client="deleteOidcClient"
|
@delete-oidc-client="deleteOidcClient"
|
||||||
@open-server-config="openServerConfig"
|
@create-realm="createRealm"
|
||||||
|
@open-realm="openRealm"
|
||||||
|
@delete-realm="deleteRealm"
|
||||||
@navigate-out="handlePanelNavigateOut"
|
@navigate-out="handlePanelNavigateOut"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1047,7 +1090,6 @@ async function submitDialog() {
|
|||||||
<AdminDialogs
|
<AdminDialogs
|
||||||
:dialog="dialog"
|
:dialog="dialog"
|
||||||
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
:permission-id-pattern="PERMISSION_ID_PATTERN"
|
||||||
:settings="authStore.settings"
|
|
||||||
@submit-dialog="submitDialog"
|
@submit-dialog="submitDialog"
|
||||||
@close-dialog="closeDialog"
|
@close-dialog="closeDialog"
|
||||||
@reset-oidc-secret="resetOidcSecret"
|
@reset-oidc-secret="resetOidcSecret"
|
||||||
|
|||||||
@@ -6,32 +6,59 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
dialog: Object,
|
dialog: Object,
|
||||||
PERMISSION_ID_PATTERN: String,
|
PERMISSION_ID_PATTERN: String
|
||||||
settings: Object
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
|
const emit = defineEmits(['submitDialog', 'closeDialog', 'resetOidcSecret', 'createPermissionForClient'])
|
||||||
|
|
||||||
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name'])
|
||||||
const NO_SUBMIT_TYPES = new Set([])
|
const NO_SUBMIT_TYPES = new Set([])
|
||||||
const rpId = computed(() => props.settings?.rp_id || 'the configured domain')
|
|
||||||
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
|
const discoveryUrl = computed(() => `${window.location.origin}/.well-known/openid-configuration`)
|
||||||
|
|
||||||
|
// The rp-id of the realm being edited in the 'realm-edit' dialog
|
||||||
|
const realmRpId = computed(() => props.dialog?.data?.rp_id || '')
|
||||||
|
|
||||||
// Initialize validation properties
|
// Initialize validation properties
|
||||||
if (props.dialog?.data && props.dialog.type === 'server-config') {
|
if (props.dialog?.data && props.dialog.type === 'realm-edit') {
|
||||||
if (!('authHostValidation' in props.dialog.data)) {
|
if (!('authHostValidation' in props.dialog.data)) {
|
||||||
props.dialog.data.authHostValidation = null
|
props.dialog.data.authHostValidation = null
|
||||||
}
|
}
|
||||||
|
if (!('originValidation' in props.dialog.data)) {
|
||||||
|
props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block submit on hard errors: malformed entries, auth-host outside the
|
||||||
|
// rp-id domain, or validation still in flight. Connectivity and rp-id
|
||||||
|
// mismatch results are warnings only (e.g. related origins hosted elsewhere,
|
||||||
|
// or a new realm whose DNS is not routed to this instance yet).
|
||||||
const isValidationInvalid = computed(() => {
|
const isValidationInvalid = computed(() => {
|
||||||
if (props.dialog?.type !== 'server-config') return false
|
if (props.dialog?.type !== 'realm-edit') return false
|
||||||
const d = props.dialog.data
|
const d = props.dialog.data
|
||||||
if (d.authHostValidation?.startsWith('invalid') || d.authHostValidation === 'validating') return true
|
if (d.authHostValidation === 'invalid-domain' || d.authHostValidation === 'validating') return true
|
||||||
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
|
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
|
||||||
|
if (props.dialog.type === 'realm-edit' && d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Well-known URL that must list any related (non-subdomain) origins.
|
||||||
|
// Browsers always fetch it from the rp-id domain, never the auth host.
|
||||||
|
const wellKnownUrl = computed(() => {
|
||||||
|
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
|
||||||
|
return host ? `https://${host}/.well-known/webauthn` : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Number of related (non-subdomain) origins in the realm dialog
|
||||||
|
const relatedOriginCount = computed(() => {
|
||||||
|
const d = props.dialog?.data
|
||||||
|
if (!d?.origins) return 0
|
||||||
|
const id = realmRpId.value
|
||||||
|
return d.origins.filter(o => {
|
||||||
|
const h = originHostname(o)
|
||||||
|
return h && id && h !== id && !h.endsWith('.' + id)
|
||||||
|
}).length
|
||||||
|
})
|
||||||
|
|
||||||
// Copy-to-clipboard helper
|
// Copy-to-clipboard helper
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
function copyText(value, label) {
|
function copyText(value, label) {
|
||||||
@@ -43,7 +70,7 @@ function copyText(value, label) {
|
|||||||
function addOrigin() {
|
function addOrigin() {
|
||||||
const d = props.dialog?.data
|
const d = props.dialog?.data
|
||||||
if (d) {
|
if (d) {
|
||||||
d.origins.push(rpId.value)
|
d.origins.push(realmRpId.value)
|
||||||
d.originValidation.push(null)
|
d.originValidation.push(null)
|
||||||
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
|
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
|
||||||
}
|
}
|
||||||
@@ -67,17 +94,32 @@ function focusOriginStart(e) {
|
|||||||
e.target.setSelectionRange(0, 0)
|
e.target.setSelectionRange(0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateOriginDomain(origin, rpId) {
|
function isWellFormedDomain(value) {
|
||||||
if (!origin.trim()) return false
|
if (!value.trim()) return false
|
||||||
try {
|
try {
|
||||||
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
const url = value.startsWith('http') ? new URL(value) : new URL('https://' + value)
|
||||||
const hostname = url.hostname
|
return url.hostname.includes('.') || url.hostname === 'localhost'
|
||||||
return hostname === rpId || hostname.endsWith('.' + rpId)
|
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function originHostname(origin) {
|
||||||
|
if (!origin.trim()) return null
|
||||||
|
try {
|
||||||
|
const url = origin.startsWith('http') ? new URL(origin) : new URL('https://' + origin)
|
||||||
|
return url.hostname || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinDomain(origin, rpId) {
|
||||||
|
const hostname = originHostname(origin)
|
||||||
|
if (!hostname) return false
|
||||||
|
return hostname === rpId || hostname.endsWith('.' + rpId)
|
||||||
|
}
|
||||||
|
|
||||||
async function validateOriginConnectivity(origin, i) {
|
async function validateOriginConnectivity(origin, i) {
|
||||||
const d = props.dialog?.data
|
const d = props.dialog?.data
|
||||||
if (!d) return
|
if (!d) return
|
||||||
@@ -90,22 +132,17 @@ async function validateOriginConnectivity(origin, i) {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Accept': 'application/json' }
|
headers: { 'Accept': 'application/json' }
|
||||||
})
|
})
|
||||||
|
if (d.origins[i] !== origin) return // origin changed while validating
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
// Valid when the origin is served by this instance for the edited realm
|
||||||
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
d.originValidation[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
|
||||||
// Only update if the origin hasn't changed
|
|
||||||
if (d.origins[i] === origin) {
|
|
||||||
d.originValidation[i] = result
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (d.origins[i] === origin) {
|
d.originValidation[i] = 'unreachable'
|
||||||
d.originValidation[i] = 'invalid'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (d.origins[i] === origin) {
|
if (d.origins[i] === origin) {
|
||||||
d.originValidation[i] = 'invalid'
|
d.originValidation[i] = 'unreachable'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,8 +151,9 @@ function validateOrigin(origin, i) {
|
|||||||
const d = props.dialog?.data
|
const d = props.dialog?.data
|
||||||
if (!d) return
|
if (!d) return
|
||||||
|
|
||||||
const id = rpId.value
|
// Related origins on unrelated domains are allowed (WebAuthn ROR), so any
|
||||||
if (validateOriginDomain(origin, id)) {
|
// well-formed origin passes; connectivity is checked as a hint only.
|
||||||
|
if (originHostname(origin)) {
|
||||||
validateOriginConnectivity(origin, i)
|
validateOriginConnectivity(origin, i)
|
||||||
} else {
|
} else {
|
||||||
d.originValidation[i] = 'invalid'
|
d.originValidation[i] = 'invalid'
|
||||||
@@ -134,22 +172,16 @@ async function validateAuthHostConnectivity(authHost) {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: { 'Accept': 'application/json' }
|
headers: { 'Accept': 'application/json' }
|
||||||
})
|
})
|
||||||
|
if (d.auth_host !== authHost) return // auth_host changed while validating
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
// Check if it returns valid settings (has rp_id and matches current rp_id)
|
d.authHostValidation = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
|
||||||
const result = (data.rp_id && data.rp_id === rpId.value) ? 'valid' : 'invalid'
|
|
||||||
// Only update if the auth_host hasn't changed
|
|
||||||
if (d.auth_host === authHost) {
|
|
||||||
d.authHostValidation = result
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (d.auth_host === authHost) {
|
d.authHostValidation = 'unreachable'
|
||||||
d.authHostValidation = 'invalid-connectivity'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (d.auth_host === authHost) {
|
if (d.auth_host === authHost) {
|
||||||
d.authHostValidation = 'invalid-connectivity'
|
d.authHostValidation = 'unreachable'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,12 +189,11 @@ async function validateAuthHostConnectivity(authHost) {
|
|||||||
function validateAuthHost() {
|
function validateAuthHost() {
|
||||||
const d = props.dialog?.data
|
const d = props.dialog?.data
|
||||||
if (!d || !d.auth_host?.trim()) {
|
if (!d || !d.auth_host?.trim()) {
|
||||||
d.authHostValidation = null // Allow empty
|
if (d) d.authHostValidation = null // Allow empty
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = rpId.value
|
if (isWithinDomain(d.auth_host, realmRpId.value)) {
|
||||||
if (validateOriginDomain(d.auth_host, id)) {
|
|
||||||
validateAuthHostConnectivity(d.auth_host)
|
validateAuthHostConnectivity(d.auth_host)
|
||||||
} else {
|
} else {
|
||||||
d.authHostValidation = 'invalid-domain'
|
d.authHostValidation = 'invalid-domain'
|
||||||
@@ -181,7 +212,7 @@ function validateAuthHost() {
|
|||||||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
||||||
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
|
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
|
||||||
<template v-else-if="dialog.type==='server-config'">Server Options</template>
|
<template v-else-if="dialog.type==='realm-edit'">{{ dialog.data?.isNew ? 'Add Realm' : 'Edit Realm' }}</template>
|
||||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||||
</h3>
|
</h3>
|
||||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||||
@@ -239,21 +270,28 @@ function validateAuthHost() {
|
|||||||
<label>Domain Scope
|
<label>Domain Scope
|
||||||
<input v-model="dialog.data.domain" data-form-type="other" />
|
<input v-model="dialog.data.domain" data-form-type="other" />
|
||||||
</label>
|
</label>
|
||||||
<p class="small muted">A domain ({{ rpId }} or subdomain) restricts this permission to that host. An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
<p class="small muted">A domain restricts this permission to that host (any configured realm's rp-id or a subdomain of it). An OIDC client UUID sends it as a <em>groups</em> claim to that client.</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="dialog.type==='server-config'">
|
<template v-else-if="dialog.type==='realm-edit'">
|
||||||
<label>Site Branding (rp-name)
|
<template v-if="dialog.data.isNew">
|
||||||
<input v-model="dialog.data.rp_name" :placeholder="rpId" />
|
<label>RP ID (domain)
|
||||||
|
<input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
|
||||||
|
</label>
|
||||||
|
<p class="small muted">The domain passkeys are registered for. Cannot be changed later.</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="small muted">Realm: <strong>{{ dialog.data.rp_id }}</strong></p>
|
||||||
|
<label>Display Name (rp-name)
|
||||||
|
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
|
||||||
</label>
|
</label>
|
||||||
<label>Dedicated Authentication Site (auth-host)
|
<label>Dedicated Authentication Site (auth-host)
|
||||||
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation?.startsWith('invalid') }" />
|
<input v-model="dialog.data.auth_host" @input="validateAuthHost()" :class="{ 'input-error': dialog.data.authHostValidation === 'invalid-domain' }" />
|
||||||
</label>
|
</label>
|
||||||
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
|
<p v-if="dialog.data.authHostValidation === 'validating'" class="small muted">Validating...</p>
|
||||||
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
|
<p v-else-if="dialog.data.authHostValidation === 'valid'" class="small muted">Valid</p>
|
||||||
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Invalid domain</p>
|
<p v-else-if="dialog.data.authHostValidation === 'invalid-domain'" class="small muted">Must be {{ dialog.data.rp_id }} or a subdomain of it.</p>
|
||||||
<p v-else-if="dialog.data.authHostValidation === 'invalid-connectivity'" class="small muted">Well-formed but unreachable</p>
|
<p v-else-if="dialog.data.authHostValidation === 'unreachable'" class="small muted">Well-formed but unreachable — make sure it is routed to this instance.</p>
|
||||||
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Invalid configuration</p>
|
<p v-else-if="dialog.data.authHostValidation === 'mismatch'" class="small muted">Reachable, but does not serve this realm.</p>
|
||||||
<p v-else-if="dialog.data.authHostValidation === 'invalid'" class="small muted">Enter {{ rpId }} or any subdomain of it.</p>
|
<p v-else class="small muted">Optional. Leave empty to serve authentication on {{ dialog.data.rp_id }} itself.</p>
|
||||||
<div class="origin-label">
|
<div class="origin-label">
|
||||||
Allowed Origins
|
Allowed Origins
|
||||||
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin">➕</button>
|
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin">➕</button>
|
||||||
@@ -269,9 +307,18 @@ function validateAuthHost() {
|
|||||||
/>
|
/>
|
||||||
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
|
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some origins are unreachable — make sure they are routed to this instance, or host them externally.</p>
|
||||||
|
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some origins are reachable but do not serve this realm.</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!dialog.data.origins.length" class="small muted">{{ rpId }} and all subdomains allowed.</p>
|
<p v-if="!dialog.data.origins.length" class="small muted">{{ dialog.data.rp_id }} and all subdomains allowed.</p>
|
||||||
<p v-else class="small muted">Only the above sites are allowed to authenticate.</p>
|
<p v-else class="small muted">Only the above sites are allowed to authenticate. Origins on unrelated domains count as related origins (max 5 per realm).</p>
|
||||||
|
<template v-if="relatedOriginCount > 0">
|
||||||
|
<p class="small muted">
|
||||||
|
Related origins require the rp-id domain to list them at
|
||||||
|
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
|
||||||
|
— this instance serves it automatically; copy it there if the main site is hosted elsewhere.
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="dialog.type==='confirm'">
|
<template v-else-if="dialog.type==='confirm'">
|
||||||
<p>{{ dialog.data.message }}</p>
|
<p>{{ dialog.data.message }}</p>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ const props = defineProps({
|
|||||||
orgs: Array,
|
orgs: Array,
|
||||||
permissions: Array,
|
permissions: Array,
|
||||||
oidcClients: Array,
|
oidcClients: Array,
|
||||||
|
realms: Array,
|
||||||
permissionSummary: Object,
|
permissionSummary: Object,
|
||||||
navigationDisabled: { type: Boolean, default: false }
|
navigationDisabled: { type: Boolean, default: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'openServerConfig', 'navigateOut'])
|
const emit = defineEmits(['createOrg', 'openOrg', 'updateOrg', 'deleteOrg', 'toggleOrgPermission', 'openDialog', 'deletePermission', 'renamePermissionDisplay', 'createOidcClient', 'openOidcClient', 'deleteOidcClient', 'createRealm', 'openRealm', 'deleteRealm', 'navigateOut'])
|
||||||
|
|
||||||
// Template refs for navigation
|
// Template refs for navigation
|
||||||
const orgActionsRef = ref(null)
|
const orgActionsRef = ref(null)
|
||||||
@@ -425,14 +426,50 @@ defineExpose({ focusFirstElement })
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isMasterAdmin" class="server-options-section">
|
<div v-if="isMasterAdmin" class="realms-section">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h2>Server</h2>
|
<h2>Realms</h2>
|
||||||
<p class="section-description">
|
<p class="section-description">
|
||||||
Configure core server settings such as the display name, authentication host, and allowed origins.
|
Each realm is one passkey rp-id with its own display name, optional dedicated auth host, and allowed origins (including Related Origin Requests origins on unrelated domains). Changes apply immediately.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="$emit('openServerConfig')">⚙ Server Options</button>
|
<div class="section-actions">
|
||||||
|
<button @click="$emit('createRealm')">+ Add Realm</button>
|
||||||
|
</div>
|
||||||
|
<table class="org-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Realm</th>
|
||||||
|
<th>Auth Host</th>
|
||||||
|
<th class="center">Origins</th>
|
||||||
|
<th class="center"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="!realms || realms.length === 0">
|
||||||
|
<td colspan="4" class="center muted">No realms configured</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="realm in realms" :key="realm.rp_id">
|
||||||
|
<td class="perm-name-cell">
|
||||||
|
<div class="perm-title">
|
||||||
|
<a :href="'#realm:' + realm.rp_id" @click.prevent="$emit('openRealm', realm)">{{ realm.rp_name || realm.rp_id }}</a>
|
||||||
|
<span v-if="realm.is_default" class="badge badge-current">Default</span>
|
||||||
|
</div>
|
||||||
|
<div class="perm-id-info">
|
||||||
|
<span class="id-text">{{ realm.rp_id }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="realm-auth-host">
|
||||||
|
<span v-if="realm.effective_auth_host">{{ realm.effective_auth_host }}<span v-if="!realm.auth_host" class="muted"> (shared)</span></span>
|
||||||
|
<span v-else class="muted">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="center">{{ realm.origins?.length || 0 }}</td>
|
||||||
|
<td class="center">
|
||||||
|
<button v-if="!realm.is_default" @click="$emit('deleteRealm', realm)" class="icon-btn delete-icon" aria-label="Delete realm" title="Delete realm">❌</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -459,7 +496,10 @@ defineExpose({ focusFirstElement })
|
|||||||
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
.oidc-clients-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||||
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
|
.client-groups { font-size: 0.85rem; color: var(--color-text-muted); max-width: 200px; font-family: var(--font-mono, monospace); }
|
||||||
|
|
||||||
/* Server Options Section */
|
/* Realms Section */
|
||||||
.server-options-section { margin-top: var(--space-2xl); }
|
.realms-section { margin-top: var(--space-2xl); }
|
||||||
.server-options-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
.realms-section .section-header { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: var(--space-md); }
|
||||||
|
.realms-section .section-actions { margin-bottom: var(--space-md); }
|
||||||
|
.realm-auth-host { font-family: var(--font-mono, monospace); font-size: 0.85rem; }
|
||||||
|
.realms-section .perm-title { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -804,6 +804,28 @@ th {
|
|||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge-realm {
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.realm-enroll-notice {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid var(--color-accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-surface-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.realm-enroll-notice p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.session-meta-info {
|
.session-meta-info {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
<h4 class="item-title">{{ getCredentialAuthName(credential) }}</h4>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
|
<span v-if="credential.rp_id && settings?.rp_id && credential.rp_id !== settings.rp_id" class="badge badge-realm" :title="`Passkey registered for ${credential.rp_id}`">{{ credential.rp_id }}</span>
|
||||||
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
<span v-if="credential.is_current_session && !hoveredCredentialUuid && !hoveredSessionCredentialUuid" class="badge badge-current">Current</span>
|
||||||
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
|
<span v-else-if="hoveredCredentialUuid === credential.credential" class="badge badge-current">Selected</span>
|
||||||
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
|
<span v-else-if="hoveredSessionCredentialUuid === credential.credential" class="badge badge-current">Linked</span>
|
||||||
@@ -61,8 +62,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
import { formatDate } from '@/utils/helpers'
|
import { formatDate } from '@/utils/helpers'
|
||||||
import { navigateGrid, handleEscape, handleDeleteKey, getDirection } from '@/utils/keynav'
|
import { navigateGrid, handleEscape, handleDeleteKey, getDirection } from '@/utils/keynav'
|
||||||
|
import { getSettings } from '@/utils/settings'
|
||||||
|
|
||||||
|
const settings = ref(null)
|
||||||
|
onMounted(async () => { settings.value = await getSettings() })
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
credentials: { type: Array, default: () => [] },
|
credentials: { type: Array, default: () => [] },
|
||||||
|
|||||||
@@ -54,6 +54,10 @@
|
|||||||
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
<p class="section-description">Ideally have at least two passkeys in case you lose one. More than one user can be registered on the same device, giving you a choice at login. <a href="https://bitwarden.com/pricing/" target="_blank" rel="noopener noreferrer">Bitwarden</a> can sync one passkey to all your devices. Other secure options include <b>local passkeys</b>, as well as hardware keys such as <a href="https://www.yubico.com" target="_blank" rel="noopener noreferrer">YubiKey</a>. Cloud sync via Google, Microsoft or iCloud is discouraged.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-body">
|
<div class="section-body">
|
||||||
|
<div v-if="missingRealmPasskey" class="realm-enroll-notice">
|
||||||
|
<p>You don't have a passkey for <strong>{{ rpName }}</strong> ({{ authStore.settings.rp_id }}) yet. Add one to log in here directly.</p>
|
||||||
|
<button @click="addNewCredential" class="btn-primary">Add Passkey for {{ authStore.settings.rp_id }}</button>
|
||||||
|
</div>
|
||||||
<CredentialList
|
<CredentialList
|
||||||
ref="credentialList"
|
ref="credentialList"
|
||||||
:credentials="credentials"
|
:credentials="credentials"
|
||||||
@@ -410,6 +414,11 @@ const hasMultipleSessions = computed(() => Object.keys(sessions.value).length >
|
|||||||
const credentials = computed(() =>
|
const credentials = computed(() =>
|
||||||
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
Object.entries(authStore.userInfo.credentials).map(([uuid, c]) => ({ ...c, credential: uuid }))
|
||||||
)
|
)
|
||||||
|
const missingRealmPasskey = computed(() => {
|
||||||
|
const rpId = authStore.settings?.rp_id
|
||||||
|
if (!rpId) return false
|
||||||
|
return !credentials.value.some(c => c.rp_id === rpId)
|
||||||
|
})
|
||||||
const useWideLayout = computed(() => {
|
const useWideLayout = computed(() => {
|
||||||
// Check if any single site has more than 8 sessions
|
// Check if any single site has more than 8 sessions
|
||||||
const groups = {}
|
const groups = {}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
<!-- Device info display (shown when 3 words match a request) -->
|
<!-- Device info display (shown when 3 words match a request) -->
|
||||||
<div v-else-if="deviceInfo" class="device-info">
|
<div v-else-if="deviceInfo" class="device-info">
|
||||||
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
|
||||||
|
<p v-if="crossRealmNotice" class="device-meta realm-notice">on <strong>{{ deviceInfo.rp_name || deviceInfo.rp_id }}</strong><template v-if="deviceInfo.rp_name"> ({{ deviceInfo.rp_id }})</template></p>
|
||||||
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
|
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
|
||||||
|
|
||||||
<p v-if="error" class="error-message">{{ error }}</p>
|
<p v-if="error" class="error-message">{{ error }}</p>
|
||||||
@@ -122,6 +123,13 @@ watch(deviceInfo, (newVal) => {
|
|||||||
emit('deviceInfoVisible', !!newVal)
|
emit('deviceInfoVisible', !!newVal)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const crossRealmNotice = computed(() => {
|
||||||
|
const info = deviceInfo.value
|
||||||
|
if (!info?.rp_id) return false
|
||||||
|
const ownRpId = settings.value?.rp_id
|
||||||
|
return ownRpId ? info.rp_id !== ownRpId : true
|
||||||
|
})
|
||||||
|
|
||||||
const hasInvalidWord = ref(false)
|
const hasInvalidWord = ref(false)
|
||||||
const serverError = ref(false)
|
const serverError = ref(false)
|
||||||
const cursorPos = ref(0)
|
const cursorPos = ref(0)
|
||||||
@@ -613,7 +621,9 @@ async function lookupDeviceInfo() {
|
|||||||
host: res.host,
|
host: res.host,
|
||||||
user_agent_pretty: res.user_agent_pretty,
|
user_agent_pretty: res.user_agent_pretty,
|
||||||
client_ip: res.client_ip,
|
client_ip: res.client_ip,
|
||||||
action: res.action || 'login'
|
action: res.action || 'login',
|
||||||
|
rp_id: res.rp_id || null,
|
||||||
|
rp_name: res.rp_name || null
|
||||||
}
|
}
|
||||||
lastLookedUpCode = currentCode
|
lastLookedUpCode = currentCode
|
||||||
nextTick(() => { submitBtnRef.value?.focus() })
|
nextTick(() => { submitBtnRef.value?.focus() })
|
||||||
@@ -937,6 +947,12 @@ defineExpose({ reset, deny, code, handleInput, loading, error })
|
|||||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.realm-notice {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
margin: 0.5rem 0 0;
|
margin: 0.5rem 0 0;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ export const useAuthStore = defineStore('auth', {
|
|||||||
if (!this.userInfo) this.currentView = 'login'
|
if (!this.userInfo) this.currentView = 'login'
|
||||||
else this.currentView = 'profile'
|
else this.currentView = 'profile'
|
||||||
},
|
},
|
||||||
async loadSettings() {
|
async loadSettings(force = false) {
|
||||||
this.settings = await getSettings()
|
this.settings = await getSettings(force)
|
||||||
},
|
},
|
||||||
async loadUserInfo() {
|
async loadUserInfo() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ let _settings = null
|
|||||||
|
|
||||||
export function getSettingsCached() { return _settings }
|
export function getSettingsCached() { return _settings }
|
||||||
|
|
||||||
export async function getSettings() {
|
export async function getSettings(force = false) {
|
||||||
|
if (force) { _settings = null; _settingsPromise = null }
|
||||||
if (_settings) return _settings
|
if (_settings) return _settings
|
||||||
if (_settingsPromise) return _settingsPromise
|
if (_settingsPromise) return _settingsPromise
|
||||||
_settingsPromise = fetch('/auth/api/settings')
|
_settingsPromise = fetch('/auth/api/settings')
|
||||||
|
|||||||
Reference in New Issue
Block a user