删除 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。
259 lines
7.4 KiB
TypeScript
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()
|
|
}
|