Pick up the single-page application as frontend

This commit is contained in:
2024-06-24 23:06:20 +08:00
parent 86b2cd8140
commit 1cf675b23a
65 changed files with 2257 additions and 1410 deletions

13
web/src/views/auth/claims.ts Executable file
View File

@@ -0,0 +1,13 @@
export interface ClaimType {
icon: string
name: string
description: string
}
export const claims: { [id: string]: ClaimType } = {
openid: {
icon: "mdi-identifier",
name: "Open Identity",
description: "Allow them to read your personal information.",
},
}

192
web/src/views/auth/connect.vue Executable file
View File

@@ -0,0 +1,192 @@
<template>
<v-container class="h-screen flex flex-col gap-3 items-center justify-center">
<v-card class="w-full max-w-[720px] overflow-auto" :loading="loading">
<v-card-text class="card-grid pa-9">
<div>
<v-avatar color="accent" icon="mdi-connection" size="large" class="card-rounded mb-2" />
<h1 class="text-2xl">Connect to third-party</h1>
<p>One Solarpass, entire internet.</p>
</div>
<v-window :touch="false" :model-value="panel" class="pa-2 mx-[-0.5rem]">
<v-window-item value="confirm">
<div class="flex flex-col gap-2">
<v-expand-transition>
<v-alert v-show="error" variant="tonal" type="error" class="text-xs mb-3">
<p>Something went wrong... {{ error }}</p>
<br />
<p class="font-bold">
It's usually not our fault. Try bringing this link to give feedback to the developer of the app you
came from.
</p>
</v-alert>
</v-expand-transition>
<div v-if="!error">
<h1 class="font-bold text-xl">{{ metadata?.name ?? "Loading" }}</h1>
<p>{{ metadata?.description ?? "Hold on a second please!" }}</p>
<div class="mt-3">
<p class="opacity-80 text-xs">Permissions they requested</p>
<v-card variant="tonal" class="mt-1 mx-[-4px]">
<v-list density="compact">
<v-list-item v-for="claim in requestedClaims" lines="two">
<template #title>
<span class="capitalize">{{ getClaimDescription(claim)?.name }}</span>
</template>
<template #subtitle>
<span>{{ getClaimDescription(claim)?.description }}</span>
</template>
<template #prepend>
<v-icon :icon="getClaimDescription(claim)?.icon" size="x-large" />
</template>
</v-list-item>
</v-list>
</v-card>
<div class="mt-5 flex justify-between">
<v-btn prepend-icon="mdi-close" variant="text" color="error" :disabled="loading" @click="decline">
Decline
</v-btn>
<v-btn append-icon="mdi-check" variant="tonal" color="success" :disabled="loading" @click="approve">
Approve
</v-btn>
</div>
<div class="mt-5 text-xs text-center opacity-75">
<p>After approve their request, you will be redirect to</p>
<p class="text-mono">{{ route.query["redirect_uri"] }}</p>
</div>
</div>
</div>
</div>
</v-window-item>
<v-window-item value="callback">
<div>
<v-icon icon="mdi-fire" size="32" color="grey-darken-3" class="mb-3" />
<h1 class="font-bold text-xl">Authoirzed</h1>
<p>You're done! We sucessfully established connection between you and {{ metadata?.name }}.</p>
<p class="mt-3">Now you can continue your their app, we will redirect you soon.</p>
<p class="mt-3">Teleporting you to...</p>
<p class="text-xs text-mono">{{ route.query["redirect_uri"] }}</p>
</div>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
<copyright />
</v-container>
</template>
<script setup lang="ts">
import { computed, ref } from "vue"
import { useRoute } from "vue-router"
import { request } from "@/scripts/request"
import { getAtk } from "@/stores/userinfo"
import { claims, type ClaimType } from "@/views/auth/claims"
import Copyright from "@/components/Copyright.vue"
const route = useRoute()
const error = ref<string | null>(null)
const loading = ref(false)
const metadata = ref<any>(null)
const requestedClaims = computed(() => {
const scope: string = (route.query["scope"] as string) ?? ""
return scope.split(" ")
})
const panel = ref("confirm")
async function preconnect() {
const res = await request(`/api/auth/o/connect${location.search}`, {
headers: { Authorization: `Bearer ${getAtk()}` },
})
if (res.status !== 200) {
error.value = await res.text()
} else {
const data = await res.json()
if (data["session"]) {
panel.value = "callback"
callback(data["session"])
} else {
document.title = `Solarpass | Connect to ${data["client"]?.name}`
metadata.value = data["client"]
loading.value = false
}
}
}
preconnect()
function decline() {
if (window.history.length > 0) {
window.history.back()
} else {
window.close()
}
}
async function approve() {
loading.value = true
const res = await request(
"/api/auth/o/connect?" +
new URLSearchParams({
client_id: route.query["client_id"] as string,
redirect_uri: encodeURIComponent(route.query["redirect_uri"] as string),
response_type: "code",
scope: route.query["scope"] as string,
}),
{
method: "POST",
headers: { Authorization: `Bearer ${getAtk()}` },
},
)
if (res.status !== 200) {
error.value = await res.text()
loading.value = false
} else {
const data = await res.json()
panel.value = "callback"
setTimeout(() => callback(data["session"]), 1850)
}
}
function callback(session: any) {
const url = `${route.query["redirect_uri"]}?code=${session["grant_token"]}&state=${route.query["state"]}`
window.open(url, "_self")
}
function getClaimDescription(key: string): ClaimType {
return claims.hasOwnProperty(key) ? claims[key] : { icon: "mdi-asterisk", name: key, description: "Unknown claim..." }
}
</script>
<style scoped>
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: 1fr;
}
}
.card-rounded {
border-radius: 8px;
}
</style>

76
web/src/views/auth/sign-in.vue Executable file
View File

@@ -0,0 +1,76 @@
<template>
<v-container class="h-screen flex flex-col gap-3 items-center justify-center">
<callback-notify />
<v-card class="w-full max-w-[720px] overflow-auto" :loading="loading">
<v-card-text class="card-grid pa-9">
<div>
<v-avatar color="accent" icon="mdi-login-variant" size="large" class="card-rounded mb-2" />
<h1 class="text-2xl">Sign in</h1>
<div v-if="challenge" class="flex items-center gap-4">
<v-tooltip>
<template v-slot:activator="{ props }">
<v-progress-circular v-bind="props" size="large"
:model-value="(challenge?.progress / challenge?.requirements) * 100" />
</template>
<p><b>Risk: </b> {{ challenge?.risk_level }}</p>
<p><b>Progress: </b> {{ challenge?.progress }}/{{ challenge?.requirements }}</p>
</v-tooltip>
<p>We need to verify that the person trying to access your account is you.</p>
</div>
<p v-else>Sign in via your Solar ID to access the entire Solar Network.</p>
</div>
<v-window :touch="false" :model-value="panel" class="pa-2 mx-[-0.5rem]">
<v-window-item v-for="k in Object.keys(panels)" :value="k">
<component :is="panels[k]" @swap="(val: string) => (panel = val)" v-model:loading="loading"
v-model:factors="factors" v-model:currentFactor="currentFactor" v-model:challenge="challenge" />
</v-window-item>
</v-window>
</v-card-text>
</v-card>
<copyright />
</v-container>
</template>
<script setup lang="ts">
import { ref, type Component } from "vue"
import Copyright from "@/components/Copyright.vue"
import CallbackNotify from "@/components/auth/CallbackNotify.vue"
import AccountLocator from "@/components/auth/AccountLocator.vue"
import FactorPicker from "@/components/auth/FactorPicker.vue"
import FactorApplicator from "@/components/auth/FactorApplicator.vue"
const loading = ref(false)
const factors = ref<any>(null)
const currentFactor = ref<any>(null)
const challenge = ref<any>(null)
const panel = ref("locate")
const panels: { [id: string]: Component } = {
locate: AccountLocator,
pick: FactorPicker,
applicator: FactorApplicator,
}
</script>
<style scoped>
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: 1fr;
}
}
.card-rounded {
border-radius: 8px;
}
</style>

162
web/src/views/auth/sign-up.vue Executable file
View File

@@ -0,0 +1,162 @@
<template>
<v-container class="h-screen flex flex-col gap-3 items-center justify-center">
<callback-notify />
<v-card class="w-full max-w-[720px] overflow-auto" :loading="loading">
<v-card-text class="card-grid pa-9">
<div>
<v-avatar color="accent" icon="mdi-login-variant" size="large" class="card-rounded mb-2" />
<h1 class="text-2xl">Create an account</h1>
<p>Create an account on Solar Network. Then enjoy all our services.</p>
</div>
<div class="flex items-center">
<v-form class="flex-grow-1" @submit.prevent="submit">
<v-row dense class="mb-3">
<v-col :cols="6">
<v-text-field
hide-details
label="Name"
autocomplete="username"
variant="solo"
density="comfortable"
v-model="data.name"
/>
</v-col>
<v-col :cols="6">
<v-text-field
hide-details
label="Nick"
autocomplete="nickname"
variant="solo"
density="comfortable"
v-model="data.nick"
/>
</v-col>
<v-col :cols="12">
<v-text-field
hide-details
label="Email Address"
type="email"
variant="solo"
density="comfortable"
v-model="data.email"
/>
</v-col>
<v-col :cols="12">
<v-text-field
hide-details
label="Password"
type="password"
autocomplete="new-password"
variant="solo"
density="comfortable"
v-model="data.password"
/>
</v-col>
</v-row>
<v-expand-transition>
<v-alert v-show="error" variant="tonal" type="error" class="text-xs mb-3">
Something went wrong... {{ error }}
</v-alert>
</v-expand-transition>
<div class="flex justify-between">
<v-btn type="button" variant="plain" color="grey-darken-3" :to="{ name: 'auth.sign-in' }">
Sign in
</v-btn>
<v-btn type="submit" variant="text" color="primary" append-icon="mdi-arrow-right" :disabled="loading">
Next
</v-btn>
</div>
</v-form>
</div>
</v-card-text>
</v-card>
<v-dialog v-model="done" class="max-w-[560px]">
<v-card title="Congratulations">
<template #text>
You successfully created an account on Solar Network. Now sign in to your account and start exploring!
</template>
<template #actions>
<div class="flex flex-grow-1 justify-end">
<v-btn @click="callback">Let's go</v-btn>
</div>
</template>
</v-card>
</v-dialog>
<copyright />
</v-container>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { request } from "@/scripts/request"
import { useRoute, useRouter } from "vue-router"
import Copyright from "@/components/Copyright.vue"
import CallbackNotify from "@/components/auth/CallbackNotify.vue"
const error = ref<string | null>(null)
const route = useRoute()
const router = useRouter()
const done = ref(false)
const loading = ref(false)
const data = ref({
name: "",
nick: "",
email: "",
password: "",
})
async function submit() {
const payload = data.value
if (!payload.name || !payload.nick || !payload.email || !payload.password) return
loading.value = true
const res = await request("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (res.status !== 200) {
error.value = await res.text()
} else {
done.value = true
error.value = null
}
loading.value = false
}
function callback() {
if (route.params["closable"]) {
window.close()
} else {
router.push({ name: "auth.sign-in" })
}
}
</script>
<style scoped>
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: 1fr;
}
}
.card-rounded {
border-radius: 8px;
}
</style>

104
web/src/views/confirm.vue Executable file
View File

@@ -0,0 +1,104 @@
<template>
<v-container class="h-screen flex flex-col gap-3 items-center justify-center">
<v-card class="w-full max-w-[720px] overflow-auto" :loading="loading">
<v-card-text class="card-grid pa-9">
<div>
<v-avatar color="accent" icon="mdi-check-decagram" size="large" class="card-rounded mb-2" />
<h1 class="text-2xl">Confirm registration</h1>
<p>Confirm your account to keep your account longer than 48 hours.</p>
</div>
<v-window :touch="false" :model-value="panel" class="pa-2 mx-[-0.5rem]">
<v-window-item value="confirm">
<div>
<v-expand-transition>
<v-alert v-show="error" variant="tonal" type="error" class="text-xs mb-3">
Something went wrong... {{ error }}
</v-alert>
</v-expand-transition>
<v-progress-circular v-if="!error" indeterminate size="32" color="grey-darken-3" class="mb-3" />
<h1 class="font-bold text-xl">Confirming</h1>
<p>We are confirming your account. Please stand by, this won't took a long time...</p>
</div>
</v-window-item>
<v-window-item value="callback">
<div>
<v-icon icon="mdi-fire" size="32" color="grey-darken-3" class="mb-3" />
<h1 class="font-bold text-xl">Confirmed</h1>
<p>You're done! We sucessfully confirmed your account.</p>
<p class="mt-3">Now you can continue use Solarpass, we will redirect to dashboard you soon.</p>
</div>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
<copyright />
</v-container>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import { request } from "@/scripts/request"
import { useUserinfo } from "@/stores/userinfo"
import Copyright from "@/components/Copyright.vue"
const route = useRoute()
const router = useRouter()
const { readProfiles } = useUserinfo()
const error = ref<string | null>(null)
const loading = ref(false)
const panel = ref("confirm")
async function confirm() {
if (!route.query["tk"]) {
error.value = "code was not exists"
return
}
const res = await request("/api/users/me/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: route.query["tk"],
}),
})
if (res.status !== 200) {
error.value = await res.text()
} else {
loading.value = true
panel.value = "callback"
await readProfiles()
router.push({ name: "dashboard" })
}
loading.value = false
}
confirm()
</script>
<style scoped>
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: 1fr;
}
}
.card-rounded {
border-radius: 8px;
}
</style>

77
web/src/views/dashboard.vue Executable file
View File

@@ -0,0 +1,77 @@
<template>
<div>
<v-card>
<v-img cover class="bg-grey-lighten-2" :height="240" :src="'/api/avatar/' + id.userinfo.data.banner" />
<v-card-text class="flex gap-3.5 px-5 pb-5">
<v-avatar
color="grey-lighten-2"
icon="mdi-account-circle"
class="rounded-card"
:size="54"
:image="'/api/avatar/' + id.userinfo.data.avatar"
/>
<div>
<h1 class="text-2xl cursor-pointer" @click="show.realname = !show.realname">{{ displayName }}</h1>
<p v-html="description"></p>
<div class="mt-5">
<p class="opacity-80 desc-line">
<v-icon icon="mdi-calendar-blank" size="16" />
<span>Joined at {{ new Date(id.userinfo.data?.created_at)?.toLocaleString() }}</span>
</p>
<p class="opacity-80 desc-line">
<v-icon icon="mdi-cake-variant" size="16" />
<span>Birthday is {{ new Date(id.userinfo.data?.profile.birthday)?.toLocaleString() }}</span>
</p>
</div>
</div>
</v-card-text>
</v-card>
</div>
</template>
<script setup lang="ts">
import { useUserinfo } from "@/stores/userinfo"
import { computed } from "vue"
import { reactive } from "vue"
import { parse } from "marked"
import dompurify from "dompurify"
const id = useUserinfo()
const displayName = computed(() => {
if (show.realname) {
return (
(id.userinfo.data?.profile?.first_name ?? "Unknown") + " " + (id.userinfo.data?.profile?.last_name ?? "Unknown")
)
} else {
return id.userinfo.displayName
}
})
const description = computed(() => {
if (id.userinfo.data?.description) {
return dompurify().sanitize(parse(id.userinfo.data?.description) as string)
} else {
return "No description yet."
}
})
const show = reactive({
realname: false,
})
</script>
<style scoped>
.desc-line {
display: flex;
align-items: center;
gap: 4px;
}
</style>
<style>
.rounded-card {
border-radius: 8px;
}
</style>

71
web/src/views/personal-page.vue Executable file
View File

@@ -0,0 +1,71 @@
<template>
<div>
<v-card class="mb-3" title="Design" prepend-icon="mdi-pencil-ruler" :loading="loading">
<template #text>
<v-form class="mt-1" @submit.prevent="submit">
<v-row dense>
<v-col :cols="12">
<v-textarea hide-details label="Content" density="comfortable" variant="outlined"
v-model="data.content" />
</v-col>
</v-row>
<v-btn type="submit" class="mt-2" variant="text" prepend-icon="mdi-content-save" :disabled="loading">
Apply Changes
</v-btn>
</v-form>
</template>
</v-card>
<v-snackbar v-model="done" :timeout="3000"> Your personal page has been updated.</v-snackbar>
<!-- @vue-ignore -->
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { getAtk } from "@/stores/userinfo";
import { request } from "@/scripts/request";
const error = ref<string | null>(null);
const done = ref(false);
const loading = ref(false);
const data = ref<any>({});
async function read() {
loading.value = true;
const res = await request("/api/users/me/page", {
headers: { Authorization: `Bearer ${(getAtk())}` }
});
if (res.status !== 200) {
error.value = await res.text();
} else {
data.value = await res.json();
}
loading.value = false;
}
async function submit() {
const payload = data.value;
loading.value = true;
const res = await request("/api/users/me/page", {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAtk()}` },
body: JSON.stringify(payload)
});
if (res.status !== 200) {
error.value = await res.text();
} else {
await read();
done.value = true;
error.value = null;
}
loading.value = false;
}
read();
</script>

170
web/src/views/personalize.vue Executable file
View File

@@ -0,0 +1,170 @@
<template>
<div>
<v-card class="mb-3" title="Information" prepend-icon="mdi-face-man-profile" :loading="loading">
<template #text>
<v-form class="mt-1" @submit.prevent="submit">
<v-row dense>
<v-col :xs="12" :md="6">
<v-text-field readonly hide-details label="Username" density="comfortable" variant="outlined"
v-model="data.name" />
</v-col>
<v-col :xs="12" :md="6">
<v-text-field hide-details label="Nickname" density="comfortable" variant="outlined"
v-model="data.nick" />
</v-col>
<v-col :cols="12">
<v-textarea hide-details label="Description" density="comfortable" variant="outlined"
v-model="data.description" />
</v-col>
<v-col :xs="12" :md="6" :lg="4">
<v-text-field hide-details label="First Name" density="comfortable" variant="outlined"
v-model="data.first_name" />
</v-col>
<v-col :xs="12" :md="6" :lg="4">
<v-text-field hide-details label="Last Name" density="comfortable" variant="outlined"
v-model="data.last_name" />
</v-col>
<v-col :xs="12" :lg="4">
<v-text-field hide-details label="Birthday" density="comfortable" variant="outlined" type="datetime-local"
v-model="data.birthday" />
</v-col>
</v-row>
<v-btn type="submit" class="mt-2" variant="text" prepend-icon="mdi-content-save" :disabled="loading">
Apply Changes
</v-btn>
</v-form>
</template>
</v-card>
<v-card>
<v-card-text class="flex items-center gap-3">
<v-avatar color="grey-lighten-2" icon="mdi-account-circle" class="rounded-card" size="large"
:image="'/api/avatar/' + id.userinfo.data.avatar" />
<v-file-input clearable hide-details label="Upload another avatar" variant="outlined" density="comfortable"
accept="image/*" prepend-icon="" append-icon="mdi-upload" v-model="avatar" @click:append="applyAvatar" />
</v-card-text>
<v-img cover class="bg-grey-lighten-2" :height="320" :src="'/api/avatar/' + id.userinfo.data.banner" />
<v-card-text>
<v-file-input clearable hide-details label="Update your banner" variant="outlined" density="comfortable"
accept="image/*" prepend-icon="" append-icon="mdi-upload" v-model="banner" @click:append="applyBanner" />
</v-card-text>
</v-card>
<v-snackbar v-model="done" :timeout="3000"> Your personal information has been updated. </v-snackbar>
<!-- @vue-ignore -->
<v-snackbar v-model="error" :timeout="5000">Something went wrong... {{ error }}</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { useUserinfo, getAtk } from "@/stores/userinfo"
import { request } from "@/scripts/request"
const id = useUserinfo()
const error = ref<string | null>(null)
const done = ref(false)
const loading = ref(false)
const data = ref<any>({})
const avatar = ref<any>(null)
const banner = ref<any>(null)
watch(
id,
(val) => {
if (val.isReady) {
data.value.name = id.userinfo.data.name
data.value.nick = id.userinfo.data.nick
data.value.description = id.userinfo.data.description
data.value.first_name = id.userinfo.data.profile.first_name
data.value.last_name = id.userinfo.data.profile.last_name
data.value.birthday = id.userinfo.data.profile.birthday
if (data.value.birthday) data.value.birthday = data.value.birthday.substring(0, 16)
}
},
{ immediate: true, deep: true },
)
async function submit() {
const payload = data.value
if (payload.birthday) payload.birthday = new Date(payload.birthday).toISOString()
loading.value = true
const res = await request("/api/users/me", {
method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getAtk()}` },
body: JSON.stringify(payload),
})
if (res.status !== 200) {
error.value = await res.text()
} else {
await id.readProfiles()
done.value = true
error.value = null
}
loading.value = false
}
async function applyAvatar() {
if (!avatar.value) return
if (loading.value) return
const payload = new FormData()
payload.set("avatar", avatar.value[0])
loading.value = true
const res = await request("/api/users/me/avatar", {
method: "PUT",
headers: { Authorization: `Bearer ${getAtk()}` },
body: payload,
})
if (res.status !== 200) {
error.value = await res.text()
} else {
await id.readProfiles()
done.value = true
error.value = null
avatar.value = null
}
loading.value = false
}
async function applyBanner() {
if (!banner.value) return
if (loading.value) return
const payload = new FormData()
payload.set("banner", banner.value[0])
loading.value = true
const res = await request("/api/users/me/banner", {
method: "PUT",
headers: { Authorization: `Bearer ${getAtk()}` },
body: payload,
})
if (res.status !== 200) {
error.value = await res.text()
} else {
await id.readProfiles()
done.value = true
error.value = null
banner.value = null
}
loading.value = false
}
</script>
<style>
.rounded-card {
border-radius: 8px;
}
</style>

266
web/src/views/security.vue Executable file
View File

@@ -0,0 +1,266 @@
<template>
<div>
<v-expansion-panels>
<v-expansion-panel eager title="Challenges">
<template #text>
<v-card :loading="reverting.challenges" variant="outlined">
<v-data-table-server
density="compact"
:headers="dataDefinitions.challenges"
:items="challenges"
:items-length="pagination.challenges.total"
:loading="reverting.challenges"
v-model:items-per-page="pagination.challenges.pageSize"
@update:options="readChallenges"
item-value="id"
>
<template v-slot:item="{ item }: { item: any }">
<tr>
<td>{{ item.id }}</td>
<td>{{ item.ip_address }}</td>
<td>
<v-tooltip :text="item.user_agent" location="top">
<template #activator="{ props }">
<div v-bind="props" class="text-ellipsis whitespace-nowrap overflow-hidden max-w-[280px]">
{{ item.user_agent }}
</div>
</template>
</v-tooltip>
</td>
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
</tr>
</template>
</v-data-table-server>
</v-card>
</template>
</v-expansion-panel>
<v-expansion-panel eager title="Sessions">
<template #text>
<v-card :loading="reverting.sessions" variant="outlined">
<v-data-table-server
density="compact"
:headers="dataDefinitions.sessions"
:items="sessions"
:items-length="pagination.sessions.total"
:loading="reverting.sessions"
v-model:items-per-page="pagination.sessions.pageSize"
@update:options="readSessions"
item-value="id"
>
<template v-slot:item="{ item }: { item: any }">
<tr>
<td>{{ item.id }}</td>
<td>
<v-chip v-for="value in item.audiences" size="x-small" color="warning" class="capitalize">
{{ value }}
</v-chip>
</td>
<td>
<v-chip v-for="value in item.claims" size="x-small" color="info" class="font-mono">
{{ value }}
</v-chip>
</td>
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
<td>
<v-tooltip text="Sign out">
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="text"
size="x-small"
color="error"
icon="mdi-logout-variant"
@click="killSession(item)"
/>
</template>
</v-tooltip>
</td>
</tr>
</template>
</v-data-table-server>
</v-card>
</template>
</v-expansion-panel>
<v-expansion-panel eager title="Events">
<template #text>
<v-card :loading="reverting.events" variant="outlined">
<v-data-table-server
density="compact"
:headers="dataDefinitions.events"
:items="events"
:items-length="pagination.events.total"
:loading="reverting.events"
v-model:items-per-page="pagination.events.pageSize"
@update:options="readEvents"
item-value="id"
>
<template v-slot:item="{ item }: { item: any }">
<tr>
<td>{{ item.id }}</td>
<td>{{ item.type }}</td>
<td>{{ item.target }}</td>
<td>{{ item.ip_address }}</td>
<td>
<v-tooltip :text="item.user_agent" location="top">
<template #activator="{ props }">
<div v-bind="props" class="text-ellipsis whitespace-nowrap overflow-hidden max-w-[180px]">
{{ item.user_agent }}
</div>
</template>
</v-tooltip>
</td>
<td>{{ new Date(item.created_at).toLocaleString() }}</td>
</tr>
</template>
</v-data-table-server>
</v-card>
</template>
</v-expansion-panel>
</v-expansion-panels>
</div>
</template>
<script setup lang="ts">
import { request } from "@/scripts/request"
import { getAtk, useUserinfo } from "@/stores/userinfo"
import { reactive, ref } from "vue"
const id = useUserinfo()
const error = ref<string | null>(null)
const dataDefinitions: { [id: string]: any[] } = {
challenges: [
{ align: "start", key: "id", title: "ID" },
{ align: "start", key: "ip_address", title: "IP Address" },
{ align: "start", key: "user_agent", title: "User Agent" },
{ align: "start", key: "created_at", title: "Issued At" },
],
sessions: [
{ align: "start", key: "id", title: "ID" },
{ align: "start", key: "audiences", title: "Audiences" },
{ align: "start", key: "claims", title: "Claims" },
{ align: "start", key: "created_at", title: "Issued At" },
{ align: "start", key: "actions", title: "Actions", sortable: false },
],
events: [
{ align: "start", key: "id", title: "ID" },
{ align: "start", key: "type", title: "Type" },
{ align: "start", key: "target", title: "Affected Object" },
{ align: "start", key: "ip_address", title: "IP Address" },
{ align: "start", key: "user_agent", title: "User Agent" },
{ align: "start", key: "created_at", title: "Performed At" },
],
}
const challenges = ref<any>([])
const sessions = ref<any>([])
const events = ref<any>([])
const reverting = reactive({ challenges: false, sessions: false, events: false })
const pagination = reactive({
challenges: { page: 1, pageSize: 5, total: 0 },
sessions: { page: 1, pageSize: 5, total: 0 },
events: { page: 1, pageSize: 5, total: 0 },
})
async function readChallenges({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
if (itemsPerPage) pagination.challenges.pageSize = itemsPerPage
if (page) pagination.challenges.page = page
reverting.challenges = true
const res = await request(
"/api/users/me/challenges?" +
new URLSearchParams({
take: pagination.challenges.pageSize.toString(),
offset: ((pagination.challenges.page - 1) * pagination.challenges.pageSize).toString(),
}),
{
headers: { Authorization: `Bearer ${getAtk()}` },
},
)
if (res.status !== 200) {
error.value = await res.text()
} else {
const data = await res.json()
challenges.value = data["data"]
pagination.challenges.total = data["count"]
}
reverting.challenges = false
}
async function readSessions({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
if (itemsPerPage) pagination.sessions.pageSize = itemsPerPage
if (page) pagination.sessions.page = page
reverting.sessions = true
const res = await request(
"/api/users/me/sessions?" +
new URLSearchParams({
take: pagination.sessions.pageSize.toString(),
offset: ((pagination.sessions.page - 1) * pagination.sessions.pageSize).toString(),
}),
{
headers: { Authorization: `Bearer ${getAtk()}` },
},
)
if (res.status !== 200) {
error.value = await res.text()
} else {
const data = await res.json()
sessions.value = data["data"]
pagination.sessions.total = data["count"]
}
reverting.sessions = false
}
async function readEvents({ page, itemsPerPage }: { page?: number; itemsPerPage?: number }) {
if (itemsPerPage) pagination.events.pageSize = itemsPerPage
if (page) pagination.events.page = page
reverting.events = true
const res = await request(
"/api/users/me/events?" +
new URLSearchParams({
take: pagination.events.pageSize.toString(),
offset: ((pagination.events.page - 1) * pagination.events.pageSize).toString(),
}),
{
headers: { Authorization: `Bearer ${getAtk()}` },
},
)
if (res.status !== 200) {
error.value = await res.text()
} else {
const data = await res.json()
events.value = data["data"]
pagination.events.total = data["count"]
}
reverting.events = false
}
Promise.all([readChallenges({}), readSessions({}), readEvents({})])
async function killSession(item: any) {
reverting.sessions = true
const res = await request(`/api/users/me/sessions/${item.id}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${getAtk()}` },
})
if (res.status !== 200) {
error.value = await res.text()
} else {
await readSessions({})
error.value = null
}
reverting.sessions = false
}
</script>
<style>
.rounded-card {
border-radius: 8px;
}
</style>