mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
feat(email/oauth): implement OAuth2 support for Gmail provider (#212)
This commit is contained in:
@@ -279,6 +279,24 @@
|
||||
"region": "Region",
|
||||
"inbound_mode": "Inbound Mode",
|
||||
"webhook_signing_key": "Webhook Signing Key"
|
||||
},
|
||||
"oauth": {
|
||||
"title": "OAuth2 Authorization",
|
||||
"description": "Authorize this provider to send emails on your behalf. You will be redirected to the provider's login page.",
|
||||
"authorize": "Authorize",
|
||||
"authorizeOpened": "Authorization page opened in a new tab",
|
||||
"authorizeFailed": "Failed to start authorization",
|
||||
"status": {
|
||||
"checking": "Checking authorization status...",
|
||||
"authorized": "Authorized as {email}",
|
||||
"authorizedUnknown": "Authorized",
|
||||
"expired": "Authorization expired — please re-authorize.",
|
||||
"missing": "Not authorized. Authorize to enable Gmail access.",
|
||||
"notConfigured": "Client ID is missing. Add it before authorizing."
|
||||
},
|
||||
"logout": "Log out",
|
||||
"logoutSuccess": "Authorization revoked",
|
||||
"logoutFailed": "Failed to revoke authorization"
|
||||
}
|
||||
},
|
||||
"browserContext": {
|
||||
|
||||
@@ -275,6 +275,24 @@
|
||||
"region": "区域",
|
||||
"inbound_mode": "入站模式",
|
||||
"webhook_signing_key": "Webhook 签名密钥"
|
||||
},
|
||||
"oauth": {
|
||||
"title": "OAuth2 授权",
|
||||
"description": "授权此提供商以您的名义发送邮件,系统将跳转到提供商登录页面。",
|
||||
"authorize": "授权",
|
||||
"authorizeOpened": "授权页面已在新标签页打开",
|
||||
"authorizeFailed": "启动授权失败",
|
||||
"status": {
|
||||
"checking": "正在检查授权状态...",
|
||||
"authorized": "已授权账号 {email}",
|
||||
"authorizedUnknown": "已授权",
|
||||
"expired": "授权已过期,请重新授权。",
|
||||
"missing": "尚未授权。完成授权后 Bot 才能访问 Gmail。",
|
||||
"notConfigured": "缺少 Client ID,填写后才能发起授权。"
|
||||
},
|
||||
"logout": "注销授权",
|
||||
"logoutSuccess": "授权已撤销",
|
||||
"logoutFailed": "撤销授权失败"
|
||||
}
|
||||
},
|
||||
"browserContext": {
|
||||
|
||||
@@ -128,6 +128,67 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OAuth authorization button for Gmail -->
|
||||
<section
|
||||
v-if="isOAuthProvider"
|
||||
class="mt-6 p-4 border rounded-lg bg-muted/30"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-[220px]">
|
||||
<p class="text-sm font-medium">
|
||||
{{ $t('emailProvider.oauth.title') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ $t('emailProvider.oauth.description') }}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs mt-2"
|
||||
:class="oauthTokenExpired ? 'text-destructive' : 'text-muted-foreground'"
|
||||
>
|
||||
<template v-if="oauthStatusLoading">
|
||||
{{ $t('emailProvider.oauth.status.checking') }}
|
||||
</template>
|
||||
<template v-else-if="oauthStatus && !oauthStatus.configured">
|
||||
{{ $t('emailProvider.oauth.status.notConfigured') }}
|
||||
</template>
|
||||
<template v-else-if="oauthTokenExpired">
|
||||
{{ $t('emailProvider.oauth.status.expired') }}
|
||||
</template>
|
||||
<template v-else-if="oauthStatus && oauthStatus.has_token">
|
||||
{{ oauthStatus.email_address ? $t('emailProvider.oauth.status.authorized', { email: oauthStatus.email_address }) : $t('emailProvider.oauth.status.authorizedUnknown') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ $t('emailProvider.oauth.status.missing') }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<LoadingButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="!canAuthorize"
|
||||
:loading="authorizeLoading"
|
||||
@click="handleAuthorize"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
:icon="['fas', 'key']"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('emailProvider.oauth.authorize') }}
|
||||
</LoadingButton>
|
||||
<LoadingButton
|
||||
v-if="hasOAuthToken"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
:loading="revokeLoading"
|
||||
@click="handleRevoke"
|
||||
>
|
||||
{{ $t('emailProvider.oauth.logout') }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="flex justify-end mt-6 gap-4">
|
||||
<ConfirmPopover
|
||||
:message="$t('emailProvider.deleteConfirm')"
|
||||
@@ -180,8 +241,20 @@ import z from 'zod'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
|
||||
import { putEmailProvidersById, deleteEmailProvidersById, getEmailProvidersMeta } from '@memoh/sdk'
|
||||
import { client } from '@memoh/sdk/client'
|
||||
import type { EmailProviderResponse, EmailFieldSchema } from '@memoh/sdk'
|
||||
|
||||
interface EmailOAuthStatusResponse {
|
||||
provider: string
|
||||
configured: boolean
|
||||
has_token: boolean
|
||||
expired: boolean
|
||||
email_address?: string
|
||||
expires_at?: string
|
||||
}
|
||||
|
||||
const OAUTH_PROVIDERS = ['gmail']
|
||||
|
||||
const { t } = useI18n()
|
||||
const curProvider = inject('curEmailProvider', ref<EmailProviderResponse>())
|
||||
const curProviderId = computed(() => curProvider.value?.id)
|
||||
@@ -205,6 +278,14 @@ const orderedFields = computed<EmailFieldSchema[]>(() => {
|
||||
return [...fields].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
||||
})
|
||||
|
||||
const isOAuthProvider = computed(() =>
|
||||
OAUTH_PROVIDERS.includes(curProvider.value?.provider ?? ''),
|
||||
)
|
||||
|
||||
const oauthStatus = ref<EmailOAuthStatusResponse | null>(null)
|
||||
const oauthStatusLoading = ref(false)
|
||||
const revokeLoading = ref(false)
|
||||
|
||||
const queryCache = useQueryCache()
|
||||
|
||||
const schema = toTypedSchema(z.object({
|
||||
@@ -226,9 +307,20 @@ watch(() => curProvider.value?.id, (id) => {
|
||||
const cfg = p.config ?? {}
|
||||
Object.keys(configData).forEach((k) => delete configData[k])
|
||||
Object.assign(configData, { ...cfg })
|
||||
if (isOAuthProvider.value) {
|
||||
void fetchOAuthStatus()
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch([isOAuthProvider, curProviderId], () => {
|
||||
if (!isOAuthProvider.value) {
|
||||
oauthStatus.value = null
|
||||
return
|
||||
}
|
||||
void fetchOAuthStatus()
|
||||
})
|
||||
|
||||
const { mutateAsync: submitUpdate, isLoading: editLoading } = useMutation({
|
||||
mutation: async (data: { name: string; config: Record<string, unknown> }) => {
|
||||
if (!curProviderId.value) return
|
||||
@@ -254,6 +346,9 @@ const handleSave = form.handleSubmit(async (values) => {
|
||||
try {
|
||||
await submitUpdate({ name: values.name, config: { ...configData } })
|
||||
toast.success(t('provider.saveChanges'))
|
||||
if (isOAuthProvider.value) {
|
||||
await fetchOAuthStatus()
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || t('common.saveFailed'))
|
||||
}
|
||||
@@ -267,4 +362,72 @@ async function handleDelete() {
|
||||
toast.error(e?.message || t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const authorizeLoading = ref(false)
|
||||
const hasOAuthToken = computed(() => Boolean(oauthStatus.value?.has_token))
|
||||
const oauthTokenExpired = computed(() => Boolean(oauthStatus.value?.has_token && oauthStatus.value?.expired))
|
||||
const canAuthorize = computed(() => {
|
||||
if (!isOAuthProvider.value) return false
|
||||
if (oauthStatusLoading.value) return false
|
||||
if (oauthStatus.value && !oauthStatus.value.configured) return false
|
||||
return true
|
||||
})
|
||||
|
||||
async function handleAuthorize() {
|
||||
if (!curProviderId.value) return
|
||||
authorizeLoading.value = true
|
||||
try {
|
||||
const { data, error } = await client.get<{ auth_url: string }, unknown>({
|
||||
url: `/email-providers/${curProviderId.value}/oauth/authorize`,
|
||||
})
|
||||
if (error || !data?.auth_url) {
|
||||
throw new Error(t('emailProvider.oauth.authorizeFailed'))
|
||||
}
|
||||
window.open(data.auth_url, '_blank', 'noopener,noreferrer')
|
||||
toast.success(t('emailProvider.oauth.authorizeOpened'))
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || t('emailProvider.oauth.authorizeFailed'))
|
||||
} finally {
|
||||
authorizeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOAuthStatus() {
|
||||
if (!isOAuthProvider.value || !curProviderId.value) {
|
||||
oauthStatus.value = null
|
||||
return
|
||||
}
|
||||
oauthStatusLoading.value = true
|
||||
try {
|
||||
const { data, error } = await client.get<EmailOAuthStatusResponse, unknown>({
|
||||
url: `/email-providers/${curProviderId.value}/oauth/status`,
|
||||
})
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
oauthStatus.value = data ?? null
|
||||
} catch (error: any) {
|
||||
oauthStatus.value = null
|
||||
console.error('failed to fetch email oauth status', error)
|
||||
} finally {
|
||||
oauthStatusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke() {
|
||||
if (!curProviderId.value) return
|
||||
revokeLoading.value = true
|
||||
try {
|
||||
const { error } = await client.delete({
|
||||
url: `/email-providers/${curProviderId.value}/oauth/token`,
|
||||
})
|
||||
if (error) throw error
|
||||
toast.success(t('emailProvider.oauth.logoutSuccess'))
|
||||
await fetchOAuthStatus()
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || t('emailProvider.oauth.logoutFailed'))
|
||||
} finally {
|
||||
revokeLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user