diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..92d382f
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "public/policy"]
+ path = public/policy
+ url = https://git.spbr.net/spacebar/policy.git
+ branch = main
diff --git a/app/app.vue b/app/app.vue
index fc033b1..0a6517e 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -41,7 +41,7 @@ onMounted(async () => {
justify-content: center;
align-items: center;
min-height: 100vh;
- padding-top: 5rem; /* Added padding-top */
+ padding-top: 5rem;
}
.app-viewport,
@@ -64,6 +64,6 @@ onMounted(async () => {
.footer-wrapper {
width: 100%;
- margin-top: 8rem; /* Added margin-top */
+ margin-top: 8rem;
}
\ No newline at end of file
diff --git a/app/components/header.vue b/app/components/header.vue
index 25535f7..db888f4 100644
--- a/app/components/header.vue
+++ b/app/components/header.vue
@@ -42,6 +42,13 @@ const emit = defineEmits(['logout', 'navigate'])
@click="emit('navigate', '/search')">
Search Users
+
+
+ Audit Logs
+
+
Sign Out
diff --git a/app/composables/useAuth.js b/app/composables/useAuth.js
index 06c93ee..b56927b 100644
--- a/app/composables/useAuth.js
+++ b/app/composables/useAuth.js
@@ -1,13 +1,39 @@
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
const userToken = ref(null)
const refreshToken = ref(null)
const userProfile = ref(null)
+const userRoles = ref([])
const isAuthenticated = ref(false)
const isInitialized = ref(false)
let sessionPollTimer = null
+const extractRolesFromToken = (token) => {
+ if (!token) return []
+ try {
+ const payloadBase64 = token.split('.')[1]
+ const base64 = payloadBase64.replace(/-/g, '+').replace(/_/g, '/')
+ const jsonPayload = decodeURIComponent(
+ atob(base64)
+ .split('')
+ .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
+ .join('')
+ )
+ const payload = JSON.parse(jsonPayload)
+
+ const realmRoles = payload?.realm_access?.roles || []
+ const clientRoles = Object.values(payload?.resource_access || {})
+ .flatMap(client => client.roles || [])
+ const directRoles = payload?.roles || []
+
+ return [...realmRoles, ...clientRoles, ...directRoles].map(r => String(r).toLowerCase())
+ } catch (e) {
+ console.warn('Failed to parse roles from token:', e)
+ return []
+ }
+}
+
const getStoredRefreshToken = () => {
if (refreshToken.value) {
return refreshToken.value
@@ -37,6 +63,8 @@ export const useAuth = () => {
const setSession = (data) => {
userToken.value = data.access_token
+ userRoles.value = extractRolesFromToken(data.access_token)
+
if (data.refresh_token) {
refreshToken.value = data.refresh_token
if (import.meta.client) {
@@ -51,6 +79,7 @@ export const useAuth = () => {
userToken.value = null
refreshToken.value = null
userProfile.value = null
+ userRoles.value = []
isAuthenticated.value = false
if (import.meta.client) {
localStorage.removeItem('keycloak_refresh_token')
@@ -69,6 +98,7 @@ export const useAuth = () => {
setSession(data)
window.history.replaceState({}, document.title, window.location.pathname)
+
await fetchUserProfile()
startLiveSessionPolling()
@@ -123,9 +153,11 @@ export const useAuth = () => {
if (res.active) {
isAuthenticated.value = true
+
if (!userProfile.value) {
await fetchUserProfile()
}
+
return true
} else {
return await silentRefresh()
@@ -144,6 +176,12 @@ export const useAuth = () => {
await navigateTo(redirectTo, { replace })
return false
}
+
+ if (result.isFirstLogin) {
+ await navigateTo('/welcome', { replace: true })
+ return true
+ }
+
return true
}
@@ -207,7 +245,9 @@ export const useAuth = () => {
return {
userToken,
+ token: userToken,
userProfile,
+ userRoles,
isAuthenticated,
isInitialized,
exchangeCodeForToken,
diff --git a/app/middleware/auth.global.js b/app/middleware/auth.global.js
index 5cc8e56..a1d7591 100644
--- a/app/middleware/auth.global.js
+++ b/app/middleware/auth.global.js
@@ -13,7 +13,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
await initializeAuth({ redirectTo: '/oauth', replace: true })
}
- //whats the shape of Italy?
if (!isAuthenticated.value) {
return navigateTo('/oauth', { replace: true })
}
diff --git a/app/pages/audit/log/[logid]/index.vue b/app/pages/audit/log/[logid]/index.vue
new file mode 100644
index 0000000..fd47396
--- /dev/null
+++ b/app/pages/audit/log/[logid]/index.vue
@@ -0,0 +1,497 @@
+
+
+
+
+
+
+
Connecting to Keycloak...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Loading log details...
+
+
+
+
{{ errorMessage }}
+
+ Return to Audit Logs
+
+
+
+
+
+
+
+
+
⛔
+
+
ACCOUNT BAN ENFORCED
+
Account level updated to Level {{ logData.ban_level }} ({{ ACCOUNT_LEVEL_NAMES[String(logData.ban_level)] || 'Banned' }})
+
+
+
+
+
Modified Attributes
+
+
+
+
+
+
+
+ PREVIOUS
+ {{ formatValue(c.field, c.oldVal) }}
+
+
→
+
+ UPDATED TO
+ {{ formatValue(c.field, c.newVal) }}
+
+
+
+
+
+
No individual field diffs were recorded for this entry.
+
+
+
+
Reason / Violation Category
+
+
{{ parsedChangesPayload.reason || logData.reason }}
+
+
+
+
+
Moderator Note & Evidence
+
+
{{ parsedChangesPayload.mod_note || logData.mod_note }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/pages/audit/log/index.vue b/app/pages/audit/log/index.vue
new file mode 100644
index 0000000..43f00f2
--- /dev/null
+++ b/app/pages/audit/log/index.vue
@@ -0,0 +1,561 @@
+
+
+
+
+
+
+
Connecting to Keycloak...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
Fetching audit logs...
+
+
+
+
+
+
+ LOG ID
+ TIMESTAMP
+ EDITOR
+ TARGET USER
+ CHANGES SUMMARY
+ ACTION
+
+
+
+
+
+ #{{ log.log_id || log.id }}
+
+
+ {{ formatDate(log.created_at) }}
+
+
+
+
e.target.src = '/img_unknown_MiiIcon.png'"
+ />
+
{{ log.editor_sfid || 'System / Unknown' }}
+
+
+
+
+
e.target.src = '/img_unknown_MiiIcon.png'"
+ />
+
+ {{ log.target_name }}
+ PID: {{ log.target_pid }}
+
+
+
+
+
+
+ Banned (Level {{ log.ban_level }})
+
+
+
+ {{ FIELD_LABELS[change.field] || change.field }}
+
+
+
+
+
+
+ Inspect
+
+
+
+
+
+
+
+
+
No audit logs found matching your criteria.
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 694a68b..e7c1ec8 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -12,9 +12,55 @@ const {
const route = useRoute()
-const recentLogins = ref([
- { user: 'admin_user', time: '2 mins ago', ip: '192.168.1.10' }
-])
+const recentActions = ref([])
+const isLoadingActions = ref(true)
+
+const FIELD_LABELS = {
+ account_level: 'Access Level',
+ country: 'Country',
+ language: 'Language',
+ timezone: 'Timezone',
+ gender: 'Gender',
+ email: 'Email',
+ marketing_allowed: 'Marketing Allowed',
+ off_device_allowed: 'Off-Device Allowed'
+}
+
+const formatDate = (isoStr) => {
+ if (!isoStr) return 'N/A'
+ try {
+ return new Date(isoStr).toLocaleString('en-US', {
+ dateStyle: 'short',
+ timeStyle: 'short'
+ })
+ } catch {
+ return isoStr
+ }
+}
+
+const parseLogChanges = (rawChanges) => {
+ if (!rawChanges) return { changes: [] }
+ if (typeof rawChanges === 'string') {
+ try {
+ return JSON.parse(rawChanges)
+ } catch {
+ return { changes: [] }
+ }
+ }
+ return rawChanges
+}
+
+const fetchRecentActions = async () => {
+ isLoadingActions.value = true
+ try {
+ const response = await $fetch('/api/admin/audit-logs?limit=3&page=1')
+ recentActions.value = response.logs || []
+ } catch (err) {
+ console.error('Failed to fetch recent actions:', err)
+ } finally {
+ isLoadingActions.value = false
+ }
+}
const handleLogout = () => {
logout()
@@ -26,6 +72,9 @@ const handleNavigate = (path) => {
onMounted(async () => {
await initializeAuth({ code: route.query.code, redirectTo: '/oauth' })
+ if (isAuthenticated.value) {
+ await fetchRecentActions()
+ }
})
@@ -68,23 +117,76 @@ onMounted(async () => {
-
\ No newline at end of file
+
+
diff --git a/app/pages/search/index.vue b/app/pages/search/index.vue
index b03fbfa..8e14b4c 100644
--- a/app/pages/search/index.vue
+++ b/app/pages/search/index.vue
@@ -16,7 +16,6 @@ const isSearching = ref(false)
const hasSearched = ref(false)
const errorMessage = ref('')
-// pagination state
const currentPage = ref(1)
const totalPages = ref(0)
const totalResults = ref(0)
@@ -30,6 +29,11 @@ const handleNavigate = (path) => {
navigateTo(path)
}
+const navigateToUser = (pid) => {
+ if (!pid) return
+ navigateTo(`/users/${pid}`)
+}
+
const executeSearch = async (page = 1) => {
const query = searchQuery.value.trim()
if (!query) return
@@ -133,7 +137,9 @@ onMounted(async () => {
{
-
- {{ user.email }}
+
+
+ {{ user.email }}
+
+
+ View →
+
@@ -193,4 +207,11 @@ onMounted(async () => {
-
\ No newline at end of file
+
+
+
\ No newline at end of file
diff --git a/app/pages/users/[pid]/index.vue b/app/pages/users/[pid]/index.vue
new file mode 100644
index 0000000..161cd0f
--- /dev/null
+++ b/app/pages/users/[pid]/index.vue
@@ -0,0 +1,665 @@
+
+
+
+
+
+
+
Connecting to Keycloak...
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading profile details...
+
+
+
+ {{ errorMessage }}
+
+
+
+ {{ successMessage }}
+
+
+
+
+
+
e.target.src = '/img_unknown_MiiIcon.png'"
+ />
+
+
+
+ {{ user.sfid || user.username || 'No Username' }}
+
+ PID: {{ user.pid }}
+ Email: {{ user.email }}
+
+
+
+
+
+ {{ userAccountLevel.title }}
+
+
+
+
+
+
+ Account Level
+ {{ userAccountLevel ? userAccountLevel.title : 'N/A' }} ({{ user.account_level ?? 'N/A' }})
+
+
+ Country
+ {{ formatCountry(user.country) }}
+
+
+ Language
+ {{ formatLanguage(user.language) }}
+
+
+ Timezone
+ {{ formatTimezone(user.timezone) }}
+
+
+ Gender
+ {{ formatGender(user.gender) }}
+
+
+ Email Verified Since
+ {{ user.email_verified_since ? new Date(user.email_verified_since).toLocaleString() : 'Unverified' }}
+
+
+ Marketing Allowed
+ {{ user.marketing_allowed ? 'Yes' : 'No' }}
+
+
+ Off-Device Allowed
+ {{ user.off_device_allowed ? 'Yes' : 'No' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/pages/welcome/index.vue b/app/pages/welcome/index.vue
index aa2d8e4..f2069d0 100644
--- a/app/pages/welcome/index.vue
+++ b/app/pages/welcome/index.vue
@@ -1,6 +1,7 @@