chore: initial commit of sso-mock SSO login service
Next.js + Mantine mock SSO service providing account/password, phone verify-code, and Feishu QR login. Issues a one-time sso_code that the external app exchanges for user info. Includes: - Feishu callback fix: guard against duplicate/concurrent /api/login submissions of the same single-use authorization code (feishuLogin once-guard + memoized onLoginSuccess to stop effect re-fire). - ADAPTATION_GUIDE.md: how to switch from the in-memory code store to the real backend auth/index + inner_get_user_info endpoints. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
41
utils/code-store.ts
Normal file
41
utils/code-store.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Mock SSO 认证码存储(内存)
|
||||
* 生产环境应使用 Redis 等持久化方案
|
||||
*/
|
||||
|
||||
interface MockUser {
|
||||
id: string
|
||||
account: string
|
||||
name: string
|
||||
phone: string
|
||||
avatar: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface CodeEntry {
|
||||
user: MockUser
|
||||
expires: number
|
||||
}
|
||||
|
||||
// 使用 globalThis 避免 Next.js 开发模式下 HMR 导致数据丢失
|
||||
const globalForCodeStore = globalThis as unknown as {
|
||||
__ssoCodeStore: Map<string, CodeEntry> | undefined
|
||||
}
|
||||
|
||||
if (!globalForCodeStore.__ssoCodeStore) {
|
||||
globalForCodeStore.__ssoCodeStore = new Map()
|
||||
}
|
||||
|
||||
export const codeStore = globalForCodeStore.__ssoCodeStore!
|
||||
|
||||
// 定时清理过期 code(仅在非 edge 环境)
|
||||
if (typeof setInterval !== 'undefined') {
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, val] of codeStore) {
|
||||
if (val.expires < now) codeStore.delete(key)
|
||||
}
|
||||
}, 60_000)
|
||||
}
|
||||
|
||||
export type { MockUser, CodeEntry }
|
||||
77
utils/message.tsx
Normal file
77
utils/message.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { IconCheck, IconX } from '@tabler/icons-react'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
|
||||
const getNotificationWidth = (text: string) => {
|
||||
const length = Math.max(1, text?.length || 0)
|
||||
const width = Math.min(Math.max(length * 14 + 80, 180), 520)
|
||||
return `${width}px`
|
||||
}
|
||||
|
||||
const getSharedStyles = (text: string) => {
|
||||
const width = getNotificationWidth(text)
|
||||
const offset = `calc((100% - ${width}) / 2)`
|
||||
|
||||
return {
|
||||
width,
|
||||
styles: {
|
||||
root: {
|
||||
top: 20,
|
||||
left: offset,
|
||||
transform: 'translateX(-75%)',
|
||||
width,
|
||||
},
|
||||
icon: {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const showSuccessMsg = (text: string, config?: object) => {
|
||||
const { width, styles } = getSharedStyles(text)
|
||||
|
||||
notifications.show({
|
||||
message: text,
|
||||
position: 'top-center',
|
||||
withCloseButton: false,
|
||||
color: '#52c41a',
|
||||
autoClose: 5000,
|
||||
icon: <IconCheck />,
|
||||
styles,
|
||||
...config,
|
||||
style: {
|
||||
width,
|
||||
...(config && 'style' in config && typeof config.style === 'object'
|
||||
? (config.style as object)
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const showErrorMsg = (text: string, config?: object) => {
|
||||
const { width, styles } = getSharedStyles(text)
|
||||
|
||||
notifications.show({
|
||||
message: text,
|
||||
position: 'top-center',
|
||||
withCloseButton: false,
|
||||
color: '#ff4d4f',
|
||||
autoClose: 5000,
|
||||
icon: <IconX size={14} />,
|
||||
styles: {
|
||||
...styles,
|
||||
root: {
|
||||
...styles.root,
|
||||
transform: 'translateX(-75%)',
|
||||
},
|
||||
},
|
||||
...config,
|
||||
style: {
|
||||
width,
|
||||
...(config && 'style' in config && typeof config.style === 'object'
|
||||
? (config.style as object)
|
||||
: {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
56
utils/session.ts
Normal file
56
utils/session.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
const SECRET_KEY = 'mock-sso-secret-key-2024'
|
||||
const TOKEN_KEY = 'sso_token'
|
||||
const USER_KEY = 'sso_user'
|
||||
|
||||
export const encrypt = (data: string) => {
|
||||
try {
|
||||
return CryptoJS.AES.encrypt(data, SECRET_KEY).toString()
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export const decrypt = (data: string) => {
|
||||
try {
|
||||
const bytes = CryptoJS.AES.decrypt(data, SECRET_KEY)
|
||||
return bytes.toString(CryptoJS.enc.Utf8)
|
||||
} catch {
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export const setAuth = (token: string, user: object) => {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(TOKEN_KEY, encrypt(token))
|
||||
localStorage.setItem(USER_KEY, encrypt(JSON.stringify(user)))
|
||||
}
|
||||
|
||||
export const getToken = (): string | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
const encrypted = localStorage.getItem(TOKEN_KEY)
|
||||
if (!encrypted) return null
|
||||
return decrypt(encrypted)
|
||||
}
|
||||
|
||||
export const getUser = (): object | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
const encrypted = localStorage.getItem(USER_KEY)
|
||||
if (!encrypted) return null
|
||||
try {
|
||||
return JSON.parse(decrypt(encrypted))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const clearAuth = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
export const isAuthenticated = (): boolean => {
|
||||
return !!getToken()
|
||||
}
|
||||
Reference in New Issue
Block a user