💄 Rework of the authorize page

This commit is contained in:
2025-11-29 18:51:29 +08:00
parent eccfc7013a
commit 7a34bc50fc
9 changed files with 200 additions and 138 deletions

View File

@@ -2,9 +2,10 @@
<naive-config>
<n-dialog-provider>
<n-notification-provider>
<naive-notification />
<n-message-provider>
<n-loading-bar-provider>
<nuxt-loading-indicator />
<naive-loading-bar navigation />
<nuxt-layout>
<nuxt-page />
</nuxt-layout>
@@ -17,22 +18,4 @@
<script setup lang="ts">
import "@fontsource-variable/nunito"
import { usePreferredColorScheme } from "@vueuse/core"
const { colorModePreference } = useNaiveColorMode()
const colorScheme = usePreferredColorScheme()
colorModePreference.set("system")
onMounted(() => {
switch (colorScheme.value) {
case "dark":
colorModePreference.set("dark")
case "light":
colorModePreference.set("light")
default:
colorModePreference.set("system")
}
colorModePreference.sync()
})
</script>

View File

@@ -36,6 +36,12 @@
gap: 1rem;
}
.h-screen-no-header {
.h-layout {
/* margin of the navbar + actual navbar */
height: calc(100vh - 64px*2);
}
.min-h-layout {
/* margin of the navbar + actual navbar */
min-height: calc(100vh - 64px*2);
}

6
app/components.d.ts vendored
View File

@@ -21,6 +21,7 @@ declare module 'vue' {
NCarousel: typeof import('naive-ui')['NCarousel']
NCarouselItem: typeof import('naive-ui')['NCarouselItem']
NChip: typeof import('naive-ui')['NChip']
NCode: typeof import('naive-ui')['NCode']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialog: typeof import('naive-ui')['NDialog']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
@@ -32,6 +33,8 @@ declare module 'vue' {
NImg: typeof import('naive-ui')['NImg']
NInfiniteScroll: typeof import('naive-ui')['NInfiniteScroll']
NInput: typeof import('naive-ui')['NInput']
NList: typeof import('naive-ui')['NList']
NListItem: typeof import('naive-ui')['NListItem']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
NMenu: typeof import('naive-ui')['NMenu']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
@@ -62,6 +65,7 @@ declare global {
const NCarousel: typeof import('naive-ui')['NCarousel']
const NCarouselItem: typeof import('naive-ui')['NCarouselItem']
const NChip: typeof import('naive-ui')['NChip']
const NCode: typeof import('naive-ui')['NCode']
const NConfigProvider: typeof import('naive-ui')['NConfigProvider']
const NDialog: typeof import('naive-ui')['NDialog']
const NDialogProvider: typeof import('naive-ui')['NDialogProvider']
@@ -73,6 +77,8 @@ declare global {
const NImg: typeof import('naive-ui')['NImg']
const NInfiniteScroll: typeof import('naive-ui')['NInfiniteScroll']
const NInput: typeof import('naive-ui')['NInput']
const NList: typeof import('naive-ui')['NList']
const NListItem: typeof import('naive-ui')['NListItem']
const NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
const NMenu: typeof import('naive-ui')['NMenu']
const NMessageProvider: typeof import('naive-ui')['NMessageProvider']

View File

@@ -3,6 +3,7 @@ import { keysToCamel, keysToSnake } from "~/utils/transformKeys"
export const useSolarNetwork = () => {
const apiBase = useSolarNetworkUrl()
const devToken = useRuntimeConfig().public.devToken
// Forward cookies from the incoming request
const headers: HeadersInit = import.meta.server
@@ -18,6 +19,11 @@ export const useSolarNetwork = () => {
const side = import.meta.server ? "SERVER" : "CLIENT"
console.log(`[useSolarNetwork] onRequest for ${request} on ${side}`)
if (devToken) {
options.headers = new Headers(options.headers)
options.headers.set("Authorization", `Bearer ${devToken}`)
}
// Transform request data from camelCase to snake_case
if (options.body && typeof options.body === "object") {
options.body = keysToSnake(options.body)

View File

@@ -1,110 +1,17 @@
<template>
<v-container class="d-flex align-center justify-center fill-height">
<v-card max-width="1000" rounded="lg" width="100%">
<div v-if="isLoading" class="d-flex justify-center mb-4">
<v-progress-linear indeterminate color="primary" height="4" />
</div>
<div class="pa-8">
<div class="mb-4">
<img :src="IconLight" alt="CloudyLamb" height="60" width="60" />
</div>
<v-row>
<v-col cols="12" lg="6" class="d-flex align-start justify-start">
<div class="md:text-left h-auto">
<h2 class="text-2xl font-bold mb-1">Authorize Application</h2>
<p class="text-lg">Grant access to your Solar Network account</p>
</div>
</v-col>
<v-col cols="12" lg="6" class="d-flex align-center justify-stretch">
<div class="w-full d-flex flex-column md:text-right">
<div v-if="error" class="mb-4">
<v-alert
type="error"
closable
@update:model-value="error = null"
>
{{ error }}
</v-alert>
</div>
<!-- App Info Section -->
<div v-if="clientInfo" class="mb-6">
<div class="d-flex align-center mb-4 text-left">
<div>
<h3 class="text-xl font-semibold">
{{ clientInfo.clientName || "Unknown Application" }}
</h3>
<p class="text-base">
{{
isNewApp
? "wants to access your Solar Network account"
: "wants to access your account"
}}
</p>
</div>
</div>
<!-- Requested Permissions -->
<v-card variant="outlined" class="pa-4 mb-4 text-left">
<h4 class="font-medium mb-2">
This will allow
{{ clientInfo.clientName || "the app" }} to
</h4>
<ul class="space-y-1">
<li
v-for="scope in requestedScopes"
:key="scope"
class="d-flex align-start"
>
<v-icon class="mt-1 mr-2" color="success" size="18"
>mdi-check</v-icon
>
<span>{{ scope }}</span>
</li>
</ul>
</v-card>
<!-- Buttons -->
<div class="d-flex gap-3 mt-4">
<v-btn
color="primary"
:loading="isAuthorizing"
class="grow"
size="large"
@click="handleAuthorize"
>
Authorize
</v-btn>
<v-btn
variant="outlined"
:disabled="isAuthorizing"
class="grow"
size="large"
@click="handleDeny"
>
Deny
</v-btn>
</div>
</div>
</div>
</v-col>
</v-row>
</div>
</v-card>
<footer-compact />
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue"
import { useRoute } from "vue-router"
import { useSolarNetwork } from "~/composables/useSolarNetwork"
import { CheckIcon } from "lucide-vue-next"
import IconLight from "~/assets/images/cloudy-lamb.png"
import type { SnCloudFile } from "~/types/api/post"
const route = useRoute()
const api = useSolarNetwork()
const message = useMessage()
useHead({
title: "Authorize Application"
})
@@ -112,11 +19,11 @@ useHead({
// State
const isLoading = ref(true)
const isAuthorizing = ref(false)
const error = ref<string | null>(null)
const clientInfo = ref<{
clientName?: string
homeUri?: string
picture?: { url: string }
picture?: SnCloudFile
background?: SnCloudFile
scopes?: string[]
} | null>(null)
const isNewApp = ref(false)
@@ -133,9 +40,7 @@ async function fetchClientInfo() {
clientInfo.value = await api(`/id/auth/open/authorize?${queryString}`)
checkIfNewApp()
} catch (err) {
error.value =
(err instanceof Error ? err.message : String(err)) ||
"An error occurred while loading the authorization request"
message.error(err instanceof Error ? err.message : String(err))
} finally {
isLoading.value = false
}
@@ -165,9 +70,7 @@ async function handleAuthorize(authorize = true) {
window.location.href = data.redirectUri
}
} catch (err) {
error.value =
(err instanceof Error ? err.message : String(err)) ||
"An error occurred during authorization"
message.error(err instanceof Error ? err.message : String(err))
} finally {
isAuthorizing.value = false
}
@@ -185,8 +88,135 @@ onMounted(() => {
definePageMeta({
middleware: "auth"
})
const apiBase = useSolarNetworkUrl()
const clientAvatar = computed(() =>
clientInfo.value?.picture
? `${apiBase}/drive/files/${clientInfo.value.picture.id}`
: undefined
)
const clientBackground = computed(() =>
clientInfo.value?.background
? `${apiBase}/drive/files/${clientInfo.value.background.id}?original=true`
: undefined
)
const pageStyle = computed(() => {
if (!clientBackground.value) return {}
return {
backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.4)), url('${clientBackground.value}')`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat"
}
})
</script>
<style scoped>
/* Add any custom styles here */
</style>
<template>
<div class="fixed inset-0 transition-all duration-500" :style="pageStyle" />
<div class="relative flex items-center justify-center min-h-layout px-4">
<n-card
:class="[
'w-full',
'max-w-[1000px]',
{ 'backdrop-blur-2xl': clientBackground },
{ 'shadow-xl': clientBackground }
]"
size="large"
:style="
clientBackground ? 'background-color: rgba(255, 255, 255, 0.1)' : ''
"
:content-style="clientBackground ? 'background-color: transparent' : ''"
>
<div v-if="isLoading" class="flex justify-center p-8">
<n-spin size="large" />
</div>
<div v-else class="p-4 md:p-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Column: Title -->
<div class="flex flex-col items-start justify-start">
<div class="mb-4">
<img :src="IconLight" alt="CloudyLamb" height="60" width="60" />
</div>
<div class="text-left h-auto">
<h2 class="text-2xl font-bold mb-1">Authorize Application</h2>
<p class="text-lg">Grant access to your Solar Network account</p>
</div>
</div>
<!-- Right Column: Content -->
<div class="flex flex-col items-end justify-stretch">
<div class="w-full flex flex-col md:text-right">
<div class="mb-3 h-[60px] px-[4px] pt-[8px]">
<n-avatar :src="clientAvatar" :size="52" />
</div>
<!-- App Info Section -->
<div v-if="clientInfo" class="mb-6">
<div class="flex flex-col items-end mb-4">
<h3 class="text-xl font-semibold">
{{ clientInfo.clientName || "Unknown Application" }}
</h3>
<p class="text-base">
{{
isNewApp
? "Wants to access your Solar Network account"
: "Wants to access your account"
}}
</p>
</div>
<!-- Requested Permissions -->
<n-card embedded class="mb-4 text-left">
<h4 class="font-medium mb-2">
This will allow
{{ clientInfo.clientName || "the app" }} to
</h4>
<n-list style="background-color: transparent" size="small">
<n-list-item
v-for="scope in requestedScopes"
:key="scope"
class="bg-transparent"
style="padding: 0"
>
<template #prefix>
<n-icon
class="mt-1 mr-2"
color="#18a058"
:size="16"
:component="CheckIcon"
/>
</template>
<span>{{ scope }}</span>
</n-list-item>
</n-list>
</n-card>
<!-- Buttons -->
<div class="flex gap-3 mt-4">
<n-button
type="primary"
:loading="isAuthorizing"
class="grow"
size="large"
@click="handleAuthorize(true)"
>
Authorize
</n-button>
<n-button
secondary
:disabled="isAuthorizing"
class="grow"
size="large"
@click="handleDeny"
>
Deny
</n-button>
</div>
</div>
</div>
</div>
</div>
</div>
</n-card>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex items-center justify-center h-screen-no-header px-4">
<div class="flex items-center justify-center h-layout px-4">
<n-card class="w-full max-w-[1000px]" size="large">
<div class="p-4 md:p-8">
<div class="mb-4">

View File

@@ -191,9 +191,9 @@ async function exchangeToken() {
await userStore.fetchUser()
const redirectUri = route.query.redirect_uri as string
const redirectUri = route.query.redirect as string
if (redirectUri) {
window.location.href = redirectUri
window.location.href = decodeURIComponent(redirectUri)
} else {
await router.push("/")
}
@@ -258,7 +258,7 @@ function getFactorIcon(factorType: number) {
</style>
<template>
<div class="flex items-center justify-center h-screen-no-header px-4">
<div class="flex flex-col gap-3 items-center justify-center h-layout px-4">
<n-card class="w-full max-w-[1000px]" size="large">
<div class="p-4 md:p-8">
<div class="mb-4">
@@ -450,5 +450,21 @@ function getFactorIcon(factorType: number) {
</div>
</div>
</n-card>
<n-alert
v-if="route.query.redirect"
class="w-full max-w-[1000px]"
type="info"
title="Login before you continue"
size="large"
>
<div class="flex flex-col gap-1">
<p>
You're requesting a page that requires authorization to access, please
login with your Solarpass and then we will redirect you to:
</p>
<n-code class="text-xs">{{ route.query.redirect }}</n-code>
</div>
</n-alert>
</div>
</template>

View File

@@ -17,7 +17,9 @@ export const useUserStore = defineStore("user", () => {
// Actions
async function fetchUser(reload = true): Promise<void> {
if (currentFetchPromise.value) {
console.log("[UserStore] Fetch already in progress. Waiting for existing fetch.")
console.log(
"[UserStore] Fetch already in progress. Waiting for existing fetch."
)
return currentFetchPromise.value
}
if (!reload && user.value) {
@@ -37,10 +39,18 @@ export const useUserStore = defineStore("user", () => {
console.log(`[UserStore] Logged in as @${user.value.name}`)
} catch (e: unknown) {
// Check for 401 Unauthorized error
const is401Error = (e instanceof FetchError && e.statusCode === 401) ||
(e && typeof e === 'object' && 'status' in e && (e as { status: number }).status === 401) ||
(e && typeof e === 'object' && 'statusCode' in e && (e as { statusCode: number }).statusCode === 401) ||
(e instanceof Error && (e.message?.includes('401') || e.message?.includes('Unauthorized')))
const is401Error =
(e instanceof FetchError && e.statusCode === 401) ||
(e &&
typeof e === "object" &&
"status" in e &&
(e as { status: number }).status === 401) ||
(e &&
typeof e === "object" &&
"statusCode" in e &&
(e as { statusCode: number }).statusCode === 401) ||
(e instanceof Error &&
(e.message?.includes("401") || e.message?.includes("Unauthorized")))
if (is401Error) {
error.value = "Unauthorized"
@@ -50,6 +60,8 @@ export const useUserStore = defineStore("user", () => {
user.value = null // Clear user data on error
console.error("Failed to fetch user... ", e)
}
console.log(`[UserStore] Logged as @${user.value!.name}`)
} finally {
isLoading.value = false
currentFetchPromise.value = null

View File

@@ -58,7 +58,8 @@ export default defineNuxtConfig({
public: {
development: process.env.NODE_ENV == "development",
apiBase: process.env.NUXT_PUBLIC_API_BASE || "https://api.solian.app",
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || "https://solian.app"
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || "https://solian.app",
devToken: process.env.NUXT_PUBLIC_DEV_TOKEN || ""
}
},
vite: {
@@ -82,6 +83,8 @@ export default defineNuxtConfig({
]
},
naiveui: {
colorModePreference: "system",
colorModePreferenceCookieName: "fi-ColorMode",
themeConfig: {
...generateTailwindColorThemes(),
shared: {