Files
what-to-eat/apps/web/app/adapters/yunlefunAuth.ts
T
toor 76a5d71252 refactor: 只保留 Web,移除 Capacitor 原生壳
删除 android/、ios/ 两个 Capacitor 原生工程与 capacitor.config.ts、
ionic.config.json,以及 scripts/android、scripts/ios.sh 构建脚本;
根与 apps/web 的 @capacitor/* 依赖和相关 npm scripts 一并移除。

Web 侧去掉原生分支:登录只走云乐坊容器授权面板或顶层重定向,
暗色模式不再改原生状态栏,移除 Android 物理返回键处理。

保留 @nuxtjs/ionic(Web UI 框架)、云乐坊 window.ylf 桥接与 PWA。

决策记录见 docs/decisions/0004-web-only.md。
2026-10-21 12:27:50 +08:00

259 lines
7.4 KiB
TypeScript

import type {
AcquisitionContext,
SsoAuthorizationResult,
} from '@yunlefun/sso'
import type {
YunlefunAccount,
YunlefunAuthorizationAdapter,
YunlefunIdentityAdapter,
} from '~/utils/yunlefunAuth'
import type { YunlefunSsoConfig } from '~/utils/yunlefunSsoConfig'
import {
adoptSsoCode,
consumeSsoRedirect,
startSsoRedirect,
} from '@yunlefun/sso'
import {
requestHostSsoAuthorization,
SsoIdentityAdoptionError,
} from '@yunlefun/sso/browser'
import { readCookAcquisition } from '~/utils/acquisition'
interface CloudbaseUser {
avatar_url?: string
email?: string
id?: string
is_anonymous?: boolean
name?: string
phone?: string
phone_number?: string
picture?: string
sub?: string
uid?: string
user_metadata?: Record<string, unknown>
username?: string
}
interface CloudbaseSession {
access_token?: string
user?: CloudbaseUser
}
export interface CloudbaseAuthClient {
getSession: () => Promise<{
data?: { session?: CloudbaseSession | null }
error?: unknown
}>
getUser: () => Promise<{
data?: { user?: CloudbaseUser | null }
error?: unknown
}>
signInWithCustomTicket: (getTicket: () => Promise<string>) => Promise<unknown>
signOut: () => Promise<{ error?: unknown } | void>
}
/** Calls a cloud function with the signed-in session and resolves its `result`. */
export type CloudbaseFunctionCaller = (name: string, data: Record<string, unknown>) => Promise<unknown>
interface CloudbaseApp {
auth: (options?: { persistence: 'session' }) => CloudbaseAuthClient
callFunction: (options: { name: string, data: Record<string, unknown> }) => Promise<{
errCode?: number | string
errMsg?: string
result?: unknown
} | undefined>
}
interface CloudbaseModule {
default?: {
init: (config: {
auth: { detectSessionInUrl: false }
env: string
region: 'ap-shanghai'
}) => CloudbaseApp
}
init?: (config: {
auth: { detectSessionInUrl: false }
env: string
region: 'ap-shanghai'
}) => CloudbaseApp
}
export interface CloudbaseIdentityDependencies {
getAuth?: (env: string) => Promise<CloudbaseAuthClient>
}
export interface WebYunlefunAuthorizationDependencies {
requestHostAuthorization?: typeof requestHostSsoAuthorization
startRedirect?: typeof startSsoRedirect
readAcquisition?: () => AcquisitionContext | null
}
let cachedCloudbaseEnv = ''
let cachedCloudbaseApp: Promise<CloudbaseApp> | undefined
let cachedCloudbaseAuth: CloudbaseAuthClient | undefined
export function createWebYunlefunAuthorizationAdapter(
config: YunlefunSsoConfig,
dependencies: WebYunlefunAuthorizationDependencies = {},
): YunlefunAuthorizationAdapter {
const requestHostAuthorization
= dependencies.requestHostAuthorization ?? requestHostSsoAuthorization
const startRedirect = dependencies.startRedirect ?? startSsoRedirect
return {
consumeInitial: async () => consumeSsoRedirect(),
start: async () => {
const acquisition = (dependencies.readAcquisition ?? readCookAcquisition)()
const redirect = { ...config.redirect, ...(acquisition ? { acquisition } : {}) }
try {
const authorization = await requestHostAuthorization({
...redirect,
prompt: 'consent',
})
if (authorization)
return authorization
}
catch (error) {
if (error instanceof SsoIdentityAdoptionError
&& error.reason === 'access_denied') {
return {
ok: false,
reason: 'access_denied',
}
}
throw error
}
await startRedirect(redirect)
return null
},
}
}
export function createCloudbaseIdentityAdapter(
config: YunlefunSsoConfig,
dependencies: CloudbaseIdentityDependencies = {},
): YunlefunIdentityAdapter {
const loadAuth = dependencies.getAuth ?? getCloudbaseAuth
return {
adopt: async (authorization: SsoAuthorizationResult) => {
const auth = await loadAuth(config.cloudbaseEnv)
const adopted = await adoptSsoCode(auth, authorization, config.exchange)
return adopted ? readAuthenticatedAccount(auth) : null
},
restore: async () => {
const auth = await loadAuth(config.cloudbaseEnv)
return readAuthenticatedAccount(auth)
},
signOut: async () => {
const auth = await loadAuth(config.cloudbaseEnv)
const result = await auth.signOut()
if (result?.error)
throw new Error('CloudBase sign-out failed')
},
}
}
function getCloudbaseApp(env: string): Promise<CloudbaseApp> {
if (cachedCloudbaseApp) {
if (cachedCloudbaseEnv !== env)
return Promise.reject(new Error('CloudBase environment cannot change at runtime'))
return cachedCloudbaseApp
}
cachedCloudbaseEnv = env
cachedCloudbaseApp = (async () => {
const cloudbaseModule = await import('@cloudbase/js-sdk') as unknown as CloudbaseModule
const cloudbase = cloudbaseModule.default ?? cloudbaseModule
if (!cloudbase.init)
throw new Error('@cloudbase/js-sdk does not expose init()')
return cloudbase.init({
auth: { detectSessionInUrl: false },
env,
region: 'ap-shanghai',
})
})()
cachedCloudbaseApp.catch(() => {
cachedCloudbaseApp = undefined
})
return cachedCloudbaseApp
}
async function getCloudbaseAuth(env: string): Promise<CloudbaseAuthClient> {
const app = await getCloudbaseApp(env)
cachedCloudbaseAuth ??= app.auth({ persistence: 'session' })
return cachedCloudbaseAuth
}
/** Call a cloud function on the same CloudBase app that holds the SSO session. */
export async function callCloudbaseFunction(
env: string,
name: string,
data: Record<string, unknown>,
): Promise<unknown> {
// Initialise auth first so the call carries the signed-in session.
await getCloudbaseAuth(env)
const app = await getCloudbaseApp(env)
const response = await app.callFunction({ name, data })
// A missing result means the call itself failed (not deployed, crashed, …).
if (!response || response.result === undefined)
throw new Error(`CloudBase function ${name} failed: ${response?.errCode ?? response?.errMsg ?? 'no result'}`)
return response.result
}
async function readAuthenticatedAccount(auth: CloudbaseAuthClient): Promise<YunlefunAccount | null> {
const sessionResult = await auth.getSession()
const session = sessionResult.data?.session
if (sessionResult.error
|| !session
|| typeof session.access_token !== 'string'
|| session.access_token.length < 16
|| session.user?.is_anonymous) {
return null
}
const userResult = await auth.getUser()
if (userResult.error)
return null
return normalizeAccount(userResult.data?.user ?? session.user)
}
function normalizeAccount(user: CloudbaseUser | null | undefined): YunlefunAccount | null {
const uid = firstString(user?.id, user?.uid, user?.sub)
if (!user || user.is_anonymous || !uid)
return null
const metadata = user.user_metadata ?? {}
const displayName = firstString(
metadata.displayName,
metadata.name,
metadata.nickName,
metadata.username,
user.name,
user.username,
user.email,
user.phone,
user.phone_number,
uid.slice(0, 8),
)
const avatarUrl = firstString(
metadata.avatarUrl,
metadata.avatar_url,
metadata.picture,
user.avatar_url,
user.picture,
)
return {
uid,
displayName: displayName ?? uid.slice(0, 8),
...(avatarUrl ? { avatarUrl } : {}),
}
}
function firstString(...values: unknown[]): string | undefined {
return values.find(value => typeof value === 'string' && value.trim())?.toString().trim()
}