Initial commit from monorepo
This commit is contained in:
275
app/pages/clientarea/profile/debug-tokens.vue
Normal file
275
app/pages/clientarea/profile/debug-tokens.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<Section variant="plain">
|
||||
<Stack gap="6">
|
||||
<PageHeader
|
||||
title="Debug Tokens"
|
||||
:actions="[
|
||||
{ label: 'Back', icon: 'lucide:arrow-left', to: localePath('/clientarea/profile') }
|
||||
]"
|
||||
/>
|
||||
|
||||
<Alert v-if="error" variant="error">
|
||||
<Text>{{ error }}</Text>
|
||||
</Alert>
|
||||
|
||||
<Card padding="lg">
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="3">JWT Tokens</Heading>
|
||||
<Button variant="ghost" size="sm" @click="loadTokens" :loading="isLoading">
|
||||
<Icon name="lucide:refresh-ccw" size="16" />
|
||||
<span>Refresh</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<!-- ID Token -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">ID Token</Heading>
|
||||
<Button v-if="rawTokens.id" variant="ghost" size="sm" @click="copyToken(rawTokens.id)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.id || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.id) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: teams -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: teams.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.teams" variant="ghost" size="sm" @click="copyToken(rawTokens.teams)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.teams || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.teams) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: exchange -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: exchange.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.exchange" variant="ghost" size="sm" @click="copyToken(rawTokens.exchange)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.exchange || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.exchange) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: orders -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: orders.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.orders" variant="ghost" size="sm" @click="copyToken(rawTokens.orders)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.orders || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.orders) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: kyc -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: kyc.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.kyc" variant="ghost" size="sm" @click="copyToken(rawTokens.kyc)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.kyc || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.kyc) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Access Token: billing -->
|
||||
<div class="space-y-2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="4">Access Token: billing.optovia.ru</Heading>
|
||||
<Button v-if="rawTokens.billing" variant="ghost" size="sm" @click="copyToken(rawTokens.billing)">
|
||||
<Icon name="lucide:copy" size="16" />
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</Stack>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Raw JWT:</Text>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
:value="rawTokens.billing || '—'"
|
||||
class="input input-bordered input-sm w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Text variant="label" class="text-xs text-base-content/60">Decoded:</Text>
|
||||
<pre class="p-3 rounded-lg bg-base-200 text-xs overflow-x-auto max-h-48">{{ formatJson(decodedTokens.billing) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const RESOURCES = {
|
||||
teams: 'https://teams.optovia.ru',
|
||||
exchange: 'https://exchange.optovia.ru',
|
||||
orders: 'https://orders.optovia.ru',
|
||||
kyc: 'https://kyc.optovia.ru',
|
||||
billing: 'https://billing.optovia.ru'
|
||||
} as const
|
||||
|
||||
type ResourceKey = keyof typeof RESOURCES
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const auth = useAuth()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const rawTokens = ref<{ id: string | null } & Record<ResourceKey, string | null>>({
|
||||
id: null,
|
||||
teams: null,
|
||||
exchange: null,
|
||||
orders: null,
|
||||
kyc: null,
|
||||
billing: null
|
||||
})
|
||||
|
||||
const decodedTokens = ref<{ id: unknown } & Record<ResourceKey, unknown>>({
|
||||
id: null,
|
||||
teams: null,
|
||||
exchange: null,
|
||||
orders: null,
|
||||
kyc: null,
|
||||
billing: null
|
||||
})
|
||||
|
||||
function decodeJwt(token?: string | null) {
|
||||
if (!token) return null
|
||||
try {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) return null
|
||||
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
return JSON.parse(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatJson(data: unknown) {
|
||||
if (!data) return '—'
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
|
||||
async function copyToken(token: string | null) {
|
||||
if (!token) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(token)
|
||||
} catch (e) {
|
||||
console.error('Failed to copy:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadTokens = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
|
||||
// Get ID token
|
||||
try {
|
||||
const idToken = await auth.getIdToken()
|
||||
rawTokens.value.id = idToken || null
|
||||
decodedTokens.value.id = decodeJwt(idToken)
|
||||
} catch (e: unknown) {
|
||||
rawTokens.value.id = null
|
||||
decodedTokens.value.id = { error: e instanceof Error ? e.message : 'Failed to get ID token' }
|
||||
}
|
||||
|
||||
// Get access tokens for ALL resources
|
||||
for (const [key, url] of Object.entries(RESOURCES) as [ResourceKey, string][]) {
|
||||
try {
|
||||
const accessToken = await auth.getAccessToken(url)
|
||||
rawTokens.value[key] = accessToken || null
|
||||
decodedTokens.value[key] = decodeJwt(accessToken)
|
||||
} catch (e: unknown) {
|
||||
rawTokens.value[key] = null
|
||||
decodedTokens.value[key] = { error: e instanceof Error ? e.message : 'Failed to get access token' }
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
error.value = err instanceof Error ? err.message : 'Error loading tokens'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTokens()
|
||||
})
|
||||
</script>
|
||||
157
app/pages/clientarea/profile/index.vue
Normal file
157
app/pages/clientarea/profile/index.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Stack gap="6">
|
||||
<PageHeader
|
||||
:title="$t('dashboard.profile')"
|
||||
:actions="[{ label: t('clientProfile.actions.debugTokens'), icon: 'lucide:bug', to: localePath('/clientarea/profile/debug-tokens') }]"
|
||||
/>
|
||||
|
||||
<Alert v-if="hasError" variant="error">
|
||||
<Stack gap="1">
|
||||
<Heading :level="4" weight="semibold">{{ $t('common.error') }}</Heading>
|
||||
<Text tone="muted">{{ error }}</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
<Stack v-if="isLoading" align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">{{ t('clientProfile.states.loading') }}</Text>
|
||||
</Stack>
|
||||
|
||||
<template v-else>
|
||||
<Card padding="lg">
|
||||
<Grid :cols="1" :lg="3" :gap="8">
|
||||
<GridItem :lg="2">
|
||||
<Stack gap="4">
|
||||
<form @submit.prevent="updateProfile">
|
||||
<Stack gap="4">
|
||||
<Input
|
||||
v-model="profileForm.firstName"
|
||||
type="text"
|
||||
:label="$t('profile.first_name')"
|
||||
:placeholder="$t('profile.first_name_placeholder')"
|
||||
/>
|
||||
<Input
|
||||
v-model="profileForm.lastName"
|
||||
type="text"
|
||||
:label="$t('profile.last_name')"
|
||||
:placeholder="$t('profile.last_name_placeholder')"
|
||||
/>
|
||||
<Input
|
||||
v-model="profileForm.phone"
|
||||
type="tel"
|
||||
:label="$t('profile.phone')"
|
||||
:placeholder="$t('profile.phone_placeholder')"
|
||||
/>
|
||||
<Button type="submit" :full-width="true" :disabled="isUpdating">
|
||||
<template v-if="isUpdating">{{ $t('profile.saving') }}...</template>
|
||||
<template v-else>{{ $t('profile.save') }}</template>
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
|
||||
<GridItem>
|
||||
<Stack gap="6" align="center">
|
||||
<Stack gap="3" align="center">
|
||||
<Heading :level="3">{{ $t('profile.avatar') }}</Heading>
|
||||
<UserAvatar
|
||||
:userId="userData?.id"
|
||||
:firstName="userData?.firstName"
|
||||
:lastName="userData?.lastName"
|
||||
:avatarId="userData?.avatarId"
|
||||
@avatar-changed="handleAvatarChange"
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</Card>
|
||||
</template>
|
||||
</Stack>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
const { mutate } = useGraphQL()
|
||||
|
||||
const userData = useState<{
|
||||
id?: string
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
phone?: string | null
|
||||
avatarId?: string | null
|
||||
} | null>('me', () => null)
|
||||
const isLoading = ref(true)
|
||||
const hasError = ref(false)
|
||||
const error = ref('')
|
||||
const isUpdating = ref(false)
|
||||
const avatarDraftId = ref<string | null>(null)
|
||||
|
||||
const profileForm = reactive({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const syncProfileForm = () => {
|
||||
if (!userData.value) {
|
||||
hasError.value = true
|
||||
error.value = t('clientProfile.error.load')
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
hasError.value = false
|
||||
error.value = ''
|
||||
profileForm.firstName = userData.value.firstName || ''
|
||||
profileForm.lastName = userData.value.lastName || ''
|
||||
profileForm.phone = userData.value.phone || ''
|
||||
avatarDraftId.value = userData.value.avatarId || null
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const updateProfile = async () => {
|
||||
try {
|
||||
isUpdating.value = true
|
||||
|
||||
const { UpdateUserDocument } = await import('~/composables/graphql/user/teams-generated')
|
||||
const result = await mutate(UpdateUserDocument, {
|
||||
userId: userData.value.id,
|
||||
input: {
|
||||
firstName: profileForm.firstName,
|
||||
lastName: profileForm.lastName,
|
||||
phone: profileForm.phone,
|
||||
avatarId: avatarDraftId.value || null
|
||||
},
|
||||
}, 'user', 'teams')
|
||||
|
||||
if (result?.updateUser?.user) {
|
||||
userData.value = { ...(userData.value || {}), ...result.updateUser.user }
|
||||
avatarDraftId.value = userData.value.avatarId || avatarDraftId.value
|
||||
}
|
||||
} catch (err) {
|
||||
hasError.value = true
|
||||
error.value = err?.message || t('clientProfile.error.save')
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarChange = async (newAvatarId?: string) => {
|
||||
if (!newAvatarId) return
|
||||
// Only stage avatar change; will be saved on form submit
|
||||
avatarDraftId.value = newAvatarId
|
||||
}
|
||||
|
||||
watch(userData, () => {
|
||||
syncProfileForm()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
Reference in New Issue
Block a user