@czxingyu/feishu-auth-vue 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,240 @@
1
+ # @czxingyu/feishu-auth-vue
2
+
3
+ 飞书登录 + JWT + 三角色 RBAC 的 Vue 前端 npm 包,与后端 `feishu-auth-spring-boot-starter`
4
+ 或同契约的后端配套使用。
5
+
6
+ 它提供新项目接飞书登录时最常重复写的部分:token 存取、飞书 OAuth 登录链路、认证 API、
7
+ axios 请求认证拦截、登录态路由守卫、角色门控、Pinia 用户 store 工厂、开发切号工具。
8
+ 菜单白名单、业务错误 toast、zod 响应校验、业务扩展字段仍留在各 app 自己实现。
9
+
10
+ ## 安装
11
+
12
+ 发布后新项目前端直接安装:
13
+
14
+ ```bash
15
+ npm install @czxingyu/feishu-auth-vue
16
+ ```
17
+
18
+ 或使用 pnpm:
19
+
20
+ ```bash
21
+ pnpm add @czxingyu/feishu-auth-vue
22
+ ```
23
+
24
+ 包已经构建为 `dist` 产物,消费方不需要把它排除出 Vite 预构建,也不需要编译它的源码。
25
+
26
+ Peer 依赖:
27
+
28
+ ```bash
29
+ npm install vue vue-router pinia
30
+ ```
31
+
32
+ `axios` 只有使用 `createAuthRequestInterceptor` 时需要。
33
+
34
+ ## 后端契约
35
+
36
+ 默认端点与 `feishu-auth-spring-boot-starter` 对齐:
37
+
38
+ | 方法 | 路径 | 说明 |
39
+ | --- | --- | --- |
40
+ | GET | `/fs/get_authorizeUrl_v1` | 返回飞书授权 URL |
41
+ | POST | `/fsLogin?code=xxx` | 飞书 code 换 JWT |
42
+ | GET | `/getFsInfo` | 当前登录用户信息 |
43
+ | POST | `/dev/switchUserByUnionId` | 开发/超管切号 |
44
+
45
+ 成功响应码兼容 `code: 200` 和 `code: 0`,方便新旧后端共用同一个前端包。
46
+
47
+ ## 新项目最小接入
48
+
49
+ ### 1. 配置 token
50
+
51
+ ```ts
52
+ // src/auth/token.ts
53
+ import { createTokenStore } from '@czxingyu/feishu-auth-vue/token'
54
+
55
+ export const tokens = createTokenStore({
56
+ tokenKey: 'MY_APP_TOKEN',
57
+ usernameKey: 'MY_APP_USERNAME',
58
+ })
59
+ ```
60
+
61
+ ### 2. 配置请求实例
62
+
63
+ ```ts
64
+ // src/api/request.ts
65
+ import axios from 'axios'
66
+ import {
67
+ createAuthRequestInterceptor,
68
+ isAuthFailureBody,
69
+ redirectToLogin,
70
+ } from '@czxingyu/feishu-auth-vue/http'
71
+ import { tokens } from '@/auth/token'
72
+
73
+ export const request = axios.create({ baseURL: '/api' })
74
+
75
+ request.interceptors.request.use(
76
+ createAuthRequestInterceptor({ getToken: tokens.getToken }),
77
+ )
78
+
79
+ request.interceptors.response.use(
80
+ (response) => {
81
+ if (isAuthFailureBody(response.data)) {
82
+ return redirectToLogin({ clearAuth: tokens.clearAuth })
83
+ }
84
+ return response.data
85
+ },
86
+ (error) => {
87
+ if (error.response?.status === 401) {
88
+ return redirectToLogin({ clearAuth: tokens.clearAuth })
89
+ }
90
+ return Promise.reject(error)
91
+ },
92
+ )
93
+ ```
94
+
95
+ ### 3. 创建认证 API
96
+
97
+ ```ts
98
+ // src/api/auth.ts
99
+ import { createAuthApi } from '@czxingyu/feishu-auth-vue/api'
100
+ import { request } from './request'
101
+
102
+ export const authApi = createAuthApi(request)
103
+ ```
104
+
105
+ 端点不同可以覆盖:
106
+
107
+ ```ts
108
+ createAuthApi(request, {
109
+ endpoints: {
110
+ fsInfo: '/api/me',
111
+ },
112
+ })
113
+ ```
114
+
115
+ ### 4. 登录页
116
+
117
+ ```vue
118
+ <script setup lang="ts">
119
+ import { useFeishuLogin } from '@czxingyu/feishu-auth-vue/useFeishuLogin'
120
+ import { authApi } from '@/api/auth'
121
+ import { tokens } from '@/auth/token'
122
+
123
+ const { status, tip } = useFeishuLogin({
124
+ api: authApi,
125
+ tokens,
126
+ defaultRedirect: '/',
127
+ })
128
+ </script>
129
+
130
+ <template>
131
+ <main>
132
+ <p :data-status="status">{{ tip.message }}</p>
133
+ </main>
134
+ </template>
135
+ ```
136
+
137
+ 登录页会自动完成:
138
+
139
+ 1. 无 `code` 时请求 `/fs/get_authorizeUrl_v1` 并跳转飞书。
140
+ 2. 飞书回调带 `code` 时调用 `/fsLogin?code=xxx`。
141
+ 3. 成功写入 token。
142
+ 4. 回跳 `redirect` 指向的站内路径。
143
+
144
+ `redirect` 会被安全归一化,外部地址、`//evil.com`、坏百分号编码都会回落到 `defaultRedirect`。
145
+
146
+ ### 5. 路由守卫
147
+
148
+ ```ts
149
+ import { getLoginRedirect, getPublicOnlyRedirect } from '@czxingyu/feishu-auth-vue/guard'
150
+ import { tokens } from '@/auth/token'
151
+
152
+ router.beforeEach((to) => {
153
+ if (to.path === '/login') {
154
+ return getPublicOnlyRedirect({ isLogin: tokens.isLogin, query: to.query })
155
+ }
156
+
157
+ const redirect = getLoginRedirect({
158
+ isLogin: tokens.isLogin,
159
+ pathname: to.path,
160
+ search: location.search,
161
+ })
162
+ if (redirect) return redirect
163
+
164
+ return true
165
+ })
166
+ ```
167
+
168
+ ### 6. 当前用户 store
169
+
170
+ ```ts
171
+ import { defineAuthUserStore } from '@czxingyu/feishu-auth-vue/store'
172
+ import type { AuthUserInfo } from '@czxingyu/feishu-auth-vue'
173
+
174
+ export const useUserInfoStore = defineAuthUserStore<AuthUserInfo>('userInfo', () => ({
175
+ id: null,
176
+ userId: null,
177
+ name: null,
178
+ unionId: null,
179
+ avatar: null,
180
+ employeeNo: null,
181
+ isExist: null,
182
+ roleLevel: null,
183
+ }))
184
+ ```
185
+
186
+ 角色门控:
187
+
188
+ ```ts
189
+ import { isAdmin, isSuperAdmin } from '@czxingyu/feishu-auth-vue/roles'
190
+
191
+ isAdmin(user.roleLevel)
192
+ isSuperAdmin(user.roleLevel)
193
+ ```
194
+
195
+ ### 7. 开发切号
196
+
197
+ ```ts
198
+ import { useUserSwitch } from '@czxingyu/feishu-auth-vue/useUserSwitch'
199
+ import { authApi } from '@/api/auth'
200
+ import { tokens } from '@/auth/token'
201
+
202
+ const { switching, switchTo } = useUserSwitch({ api: authApi, tokens })
203
+ ```
204
+
205
+ 准入由后端控制。开发环境可以放行,生产环境建议仅超管允许。
206
+
207
+ ## 子入口
208
+
209
+ 推荐新项目按需从子入口导入,这样依赖边界更清楚:
210
+
211
+ ```ts
212
+ import { createTokenStore } from '@czxingyu/feishu-auth-vue/token'
213
+ import { createAuthApi } from '@czxingyu/feishu-auth-vue/api'
214
+ import { useFeishuLogin } from '@czxingyu/feishu-auth-vue/useFeishuLogin'
215
+ ```
216
+
217
+ 根入口也可用:
218
+
219
+ ```ts
220
+ import { createTokenStore, useFeishuLogin } from '@czxingyu/feishu-auth-vue'
221
+ ```
222
+
223
+ ## 包维护
224
+
225
+ ```bash
226
+ pnpm install
227
+ pnpm test
228
+ pnpm run type-check
229
+ pnpm run build
230
+ npm pack --dry-run
231
+ ```
232
+
233
+ 发布到 npm:
234
+
235
+ ```bash
236
+ npm login
237
+ npm publish --access public
238
+ ```
239
+
240
+ 如果发布到公司私有 npm registry,请在项目或 CI 中配置对应 registry 后再 `npm publish`。
package/dist/api.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * 认证相关 API 封装。给定一个「请求实例」(响应已解包成 body),
3
+ * 返回绑定到后端标准登录端点的方法集。端点路径可覆盖。
4
+ *
5
+ * 默认端点与 feishu-auth-spring-boot-starter 的 Controller 对齐:
6
+ * - /fsLogin?code=xxx POST 飞书 code 换 token
7
+ * - /getFsInfo GET 当前登录用户信息
8
+ * - /fs/get_authorizeUrl_v1 GET 飞书授权跳转地址
9
+ * - /dev/switchUserByUnionId POST 按 unionId 切号
10
+ */
11
+ import type { ApiEnvelope, AuthRequest, AuthUserInfo, FsLoginResult } from './types';
12
+ export interface AuthEndpoints {
13
+ fsLogin: string;
14
+ fsInfo: string;
15
+ authorizeUrl: string;
16
+ devSwitch: string;
17
+ }
18
+ declare const DEFAULT_ENDPOINTS: AuthEndpoints;
19
+ export interface AuthApi {
20
+ /** 飞书授权码换 token。 */
21
+ fsLogin(code: string): Promise<FsLoginResult>;
22
+ /** 当前登录用户信息。U 默认 AuthUserInfo,可传系统扩展后的用户类型。 */
23
+ getFsInfo<U = AuthUserInfo>(): Promise<ApiEnvelope<U>>;
24
+ /** 飞书授权跳转地址。 */
25
+ getAuthorizeUrl(): Promise<ApiEnvelope<string>>;
26
+ /** 按 unionId 切号(后端按 dev/超管准入)。 */
27
+ devSwitchUser(unionId: string): Promise<FsLoginResult>;
28
+ }
29
+ export interface CreateAuthApiOptions {
30
+ /** 覆盖部分端点路径。 */
31
+ endpoints?: Partial<AuthEndpoints>;
32
+ }
33
+ export declare function createAuthApi(request: AuthRequest, options?: CreateAuthApiOptions): AuthApi;
34
+ /**
35
+ * 从登录/切号返回壳里提取 token。兼容三种结构:
36
+ * { token } 顶层(后端 FsLoginResponse)
37
+ * { data: "<token>" } data 直接是字符串
38
+ * { data: { token } } data 里嵌 token
39
+ */
40
+ export declare function extractLoginToken(res: FsLoginResult | undefined | null): string | undefined;
41
+ export { DEFAULT_ENDPOINTS };
package/dist/api.js ADDED
@@ -0,0 +1,30 @@
1
+ const i = {
2
+ fsLogin: "/fsLogin",
3
+ fsInfo: "/getFsInfo",
4
+ authorizeUrl: "/fs/get_authorizeUrl_v1",
5
+ devSwitch: "/dev/switchUserByUnionId"
6
+ };
7
+ function r(o, t = {}) {
8
+ const n = { ...i, ...t.endpoints };
9
+ return {
10
+ fsLogin: (e) => o.post(`${n.fsLogin}?code=${encodeURIComponent(e)}`, {}),
11
+ getFsInfo: () => o.get(n.fsInfo),
12
+ getAuthorizeUrl: () => o.get(n.authorizeUrl),
13
+ devSwitchUser: (e) => o.post(n.devSwitch, { unionId: e })
14
+ };
15
+ }
16
+ function f(o) {
17
+ if (!o) return;
18
+ const t = o;
19
+ if (typeof t.data == "string") return t.data;
20
+ if (t.data && typeof t.data == "object") {
21
+ const n = t.data.token;
22
+ if (typeof n == "string") return n;
23
+ }
24
+ if (typeof t.token == "string") return t.token;
25
+ }
26
+ export {
27
+ i as DEFAULT_ENDPOINTS,
28
+ r as createAuthApi,
29
+ f as extractLoginToken
30
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * 路由守卫助手——只负责通用的「登录态」判断:
3
+ * - 未登录 → 跳登录页(带 redirect 回跳)
4
+ * - 已登录 → 放行
5
+ *
6
+ * 菜单白名单这类「系统专属」的权限校验不在这里,留给各 app 自己实现
7
+ * (与后端把 SecurityFilterChain 留给使用方同理)。
8
+ */
9
+ export interface LoginRedirectOptions {
10
+ /** 是否已登录(通常传 tokenStore.isLogin)。 */
11
+ isLogin: () => boolean;
12
+ /** 当前路径(不含 query),如 route.path / location.pathname。 */
13
+ pathname: string;
14
+ /** 当前 query 串(以 ? 开头或空串),如 location.search。 */
15
+ search: string;
16
+ /** 登录页路径,默认 /login。 */
17
+ loginPath?: string;
18
+ }
19
+ /**
20
+ * 权限守卫的「登录态」部分:
21
+ * - 未登录且 URL 带飞书回调 code → 跳 `${loginPath}${search}`(让登录页消费 code)
22
+ * - 未登录且无 code → 跳 `${loginPath}?redirect=<当前路径>`
23
+ * - 已登录 → 返回 null(放行,后续菜单/业务校验由 app 自己接)
24
+ */
25
+ export declare function getLoginRedirect(options: LoginRedirectOptions): string | null;
26
+ export interface PublicOnlyRedirectOptions {
27
+ isLogin: () => boolean;
28
+ /** 当前路由 query;带 code(飞书回调)时即使已登录也放行。 */
29
+ query?: Record<string, unknown>;
30
+ /** 已登录时重定向到的首页,默认 /。 */
31
+ home?: string;
32
+ }
33
+ /**
34
+ * 「仅未登录可访问」守卫(用于登录页):
35
+ * - 带飞书回调 code → null(放行,确保 code 能被换 token)
36
+ * - 已登录 → 返回首页路径
37
+ * - 未登录 → null(放行)
38
+ */
39
+ export declare function getPublicOnlyRedirect(options: PublicOnlyRedirectOptions): string | null;
package/dist/guard.js ADDED
@@ -0,0 +1,14 @@
1
+ function t(e) {
2
+ const r = e.loginPath ?? "/login";
3
+ if (e.isLogin()) return null;
4
+ if (new URLSearchParams(e.search).get("code")) return `${r}${e.search}`;
5
+ const n = encodeURIComponent(e.pathname + e.search);
6
+ return `${r}?redirect=${n}`;
7
+ }
8
+ function u(e) {
9
+ return e.query?.code ? null : e.isLogin() ? e.home ?? "/" : null;
10
+ }
11
+ export {
12
+ t as getLoginRedirect,
13
+ u as getPublicOnlyRedirect
14
+ };
package/dist/http.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * axios 认证拦截助手——刻意做成「可组合」而非「接管整个拦截器」:
3
+ * 各 app 的响应拦截器里往往还塞了信封校验、错误 toast 等业务逻辑,本包不抢这些,
4
+ * 只提供登录态相关的几块,让 app 拼进自己的拦截器(与后端不替使用方配 SecurityFilterChain 同理)。
5
+ */
6
+ import type { InternalAxiosRequestConfig } from 'axios';
7
+ export interface AuthRequestInterceptorOptions {
8
+ /** 取当前 token(通常传 tokenStore.getToken)。 */
9
+ getToken: () => string | null;
10
+ /** URL 命中其中任一子串则不带 token(默认 ['/fsLogin'],避免旧 token 干扰登录)。 */
11
+ noAuthUrls?: string[];
12
+ /** 注入的请求头名,默认 Authorization。 */
13
+ headerName?: string;
14
+ /** token 前缀,默认 Bearer。 */
15
+ scheme?: string;
16
+ }
17
+ /**
18
+ * 生成「请求拦截器」函数:自动给请求带上 Authorization: Bearer <token>。
19
+ * 用法:instance.interceptors.request.use(createAuthRequestInterceptor({ getToken }))
20
+ */
21
+ export declare function createAuthRequestInterceptor(options: AuthRequestInterceptorOptions): (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig;
22
+ /** 后端习惯把认证失败放在 HTTP 200 响应体的 code 字段里(如 {code:401})。 */
23
+ export declare function isAuthFailureBody(body: unknown): boolean;
24
+ export interface RedirectToLoginOptions {
25
+ /** 清除本地认证信息(通常传 tokenStore.clearAuth)。 */
26
+ clearAuth: () => void;
27
+ /** 登录页路径,默认 /login。 */
28
+ loginPath?: string;
29
+ }
30
+ /**
31
+ * 认证失败统一处理:清 token、整页跳登录(带 redirect 回跳)、返回永远 pending 的 Promise,
32
+ * 让调用方的 .catch 不再继续(页面即将整页跳转)。
33
+ */
34
+ export declare function redirectToLogin(options: RedirectToLoginOptions): Promise<never>;
package/dist/http.js ADDED
@@ -0,0 +1,27 @@
1
+ function u(e) {
2
+ const o = e.noAuthUrls ?? ["/fsLogin"], n = e.headerName ?? "Authorization", s = e.scheme ?? "Bearer";
3
+ return (t) => {
4
+ const a = t.url ?? "";
5
+ if (o.some((r) => a.includes(r))) return t;
6
+ const i = e.getToken();
7
+ if (i) {
8
+ const r = `${s} ${i}`, c = t.headers;
9
+ typeof c.set == "function" ? c.set(n, r) : c[n] = r;
10
+ }
11
+ return t;
12
+ };
13
+ }
14
+ function h(e) {
15
+ return !!e && typeof e == "object" && e.code === 401;
16
+ }
17
+ function l(e) {
18
+ e.clearAuth();
19
+ const o = e.loginPath ?? "/login", n = window.location.pathname + window.location.search, t = n.startsWith(o) ? "" : `?redirect=${encodeURIComponent(n)}`;
20
+ return window.location.href = `${o}${t}`, new Promise(() => {
21
+ });
22
+ }
23
+ export {
24
+ u as createAuthRequestInterceptor,
25
+ h as isAuthFailureBody,
26
+ l as redirectToLogin
27
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * @xy/feishu-auth-vue —— 飞书登录 + JWT + 三角色 RBAC 的前端可复用件。
3
+ *
4
+ * 与后端 feishu-auth-spring-boot-starter 配套:抽出 token 存取、登录 OAuth 链路、
5
+ * 切号、axios 认证拦截、登录态守卫、角色门控、用户 store 工厂等通用件;
6
+ * 菜单白名单、错误 toast、信封校验等系统专属逻辑留给各 app。
7
+ */
8
+ export type { ApiEnvelope, FsLoginResult, AuthUserInfo, RoleAware, AuthRequest, LoginStatus, LoginTip, } from './types';
9
+ export { parseJwtPayload, createTokenStore, defaultTokenStore, setToken, getToken, removeToken, clearAuth, isLogin, getUsername, } from './token';
10
+ export type { TokenStore, TokenStoreOptions } from './token';
11
+ export { createAuthApi, extractLoginToken, DEFAULT_ENDPOINTS } from './api';
12
+ export type { AuthApi, AuthEndpoints, CreateAuthApiOptions } from './api';
13
+ export { isSuccessCode, isSuccessEnvelope } from './response';
14
+ export { createAuthRequestInterceptor, isAuthFailureBody, redirectToLogin } from './http';
15
+ export type { AuthRequestInterceptorOptions, RedirectToLoginOptions } from './http';
16
+ export { getLoginRedirect, getPublicOnlyRedirect } from './guard';
17
+ export type { LoginRedirectOptions, PublicOnlyRedirectOptions } from './guard';
18
+ export { normalizeSafeRedirect, safeDecodeURIComponent, stripQueryParam } from './redirect';
19
+ export { isAdmin, isSuperAdmin, isAdminUser, isSuperAdminUser } from './roles';
20
+ export { defineAuthUserStore } from './store';
21
+ export { useFeishuLogin } from './useFeishuLogin';
22
+ export type { UseFeishuLoginOptions, UseFeishuLoginReturn } from './useFeishuLogin';
23
+ export { useUserSwitch } from './useUserSwitch';
24
+ export type { UseUserSwitchOptions } from './useUserSwitch';
package/dist/index.js ADDED
@@ -0,0 +1,41 @@
1
+ import { clearAuth as o, createTokenStore as t, defaultTokenStore as i, getToken as s, getUsername as n, isLogin as m, parseJwtPayload as p, removeToken as c, setToken as u } from "./token.js";
2
+ import { DEFAULT_ENDPOINTS as d, createAuthApi as f, extractLoginToken as x } from "./api.js";
3
+ import { isSuccessCode as S, isSuccessEnvelope as g } from "./response.js";
4
+ import { createAuthRequestInterceptor as l, isAuthFailureBody as h, redirectToLogin as U } from "./http.js";
5
+ import { getLoginRedirect as L, getPublicOnlyRedirect as R } from "./guard.js";
6
+ import { normalizeSafeRedirect as P, safeDecodeURIComponent as D, stripQueryParam as E } from "./redirect.js";
7
+ import { isAdmin as I, isAdminUser as v, isSuperAdmin as w, isSuperAdminUser as C } from "./roles.js";
8
+ import { defineAuthUserStore as O } from "./store.js";
9
+ import { useFeishuLogin as q } from "./useFeishuLogin.js";
10
+ import { useUserSwitch as B } from "./useUserSwitch.js";
11
+ export {
12
+ d as DEFAULT_ENDPOINTS,
13
+ o as clearAuth,
14
+ f as createAuthApi,
15
+ l as createAuthRequestInterceptor,
16
+ t as createTokenStore,
17
+ i as defaultTokenStore,
18
+ O as defineAuthUserStore,
19
+ x as extractLoginToken,
20
+ L as getLoginRedirect,
21
+ R as getPublicOnlyRedirect,
22
+ s as getToken,
23
+ n as getUsername,
24
+ I as isAdmin,
25
+ v as isAdminUser,
26
+ h as isAuthFailureBody,
27
+ m as isLogin,
28
+ S as isSuccessCode,
29
+ g as isSuccessEnvelope,
30
+ w as isSuperAdmin,
31
+ C as isSuperAdminUser,
32
+ P as normalizeSafeRedirect,
33
+ p as parseJwtPayload,
34
+ U as redirectToLogin,
35
+ c as removeToken,
36
+ D as safeDecodeURIComponent,
37
+ u as setToken,
38
+ E as stripQueryParam,
39
+ q as useFeishuLogin,
40
+ B as useUserSwitch
41
+ };
@@ -0,0 +1,13 @@
1
+ /** 安全解码 URL 组件,坏编码直接返回 null。 */
2
+ export declare function safeDecodeURIComponent(value: string): string | null;
3
+ /** 从相对路径里删除一个 query 参数,不改变其它参数。 */
4
+ export declare function stripQueryParam(path: string, paramName: string): string;
5
+ /**
6
+ * 把登录后跳转地址归一成安全的站内路径:
7
+ * - 接受 /path 和被 encodeURIComponent 过的 /path
8
+ * - 拒绝 //evil、http(s)://evil、坏百分号编码
9
+ * - 默认剔除飞书回调 code,避免回到业务页时重复消费 code
10
+ */
11
+ export declare function normalizeSafeRedirect(redirect: string | undefined | null, fallback?: string, options?: {
12
+ stripCode?: boolean;
13
+ }): string;
@@ -0,0 +1,26 @@
1
+ function a(t) {
2
+ try {
3
+ return decodeURIComponent(t);
4
+ } catch {
5
+ return null;
6
+ }
7
+ }
8
+ function i(t, e) {
9
+ const [s, r = ""] = t.split("?", 2);
10
+ if (!r) return t;
11
+ const n = new URLSearchParams(r);
12
+ n.delete(e);
13
+ const o = n.toString();
14
+ return o ? `${s}?${o}` : s;
15
+ }
16
+ function u(t, e = "/", s = {}) {
17
+ const r = e.startsWith("/") && !e.startsWith("//") ? e : "/";
18
+ if (!t) return r;
19
+ const n = t.startsWith("/") && !t.startsWith("//") ? t : a(t);
20
+ return !n || !n.startsWith("/") || n.startsWith("//") ? r : s.stripCode === !1 ? n : i(n, "code");
21
+ }
22
+ export {
23
+ u as normalizeSafeRedirect,
24
+ a as safeDecodeURIComponent,
25
+ i as stripQueryParam
26
+ };
@@ -0,0 +1,4 @@
1
+ import type { ApiEnvelope } from './types';
2
+ /** 后端 starter 历史上出现过两种成功码:LightFlow 用 200,一些新项目用 0。 */
3
+ export declare function isSuccessCode(code: unknown): boolean;
4
+ export declare function isSuccessEnvelope<T = unknown>(value: unknown): value is ApiEnvelope<T>;
@@ -0,0 +1,10 @@
1
+ function c(e) {
2
+ return e === 0 || e === 200;
3
+ }
4
+ function n(e) {
5
+ return !!e && typeof e == "object" && c(e.code);
6
+ }
7
+ export {
8
+ c as isSuccessCode,
9
+ n as isSuccessEnvelope
10
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * 角色等级门控助手。纯函数,无依赖。
3
+ *
4
+ * roleLevel 约定(与后端三角色一致):0 普通用户 / 1 管理员 / 2 超管。
5
+ * 仅用于前端 UI 门控(隐藏修改类按钮/入口);后端仍走 @PreAuthorize 独立校验。
6
+ */
7
+ import type { RoleAware } from './types';
8
+ /** >=1 视为管理员(有"修改"权限)。 */
9
+ export declare function isAdmin(roleLevel: number | null | undefined): boolean;
10
+ /** >=2 视为超级管理员。 */
11
+ export declare function isSuperAdmin(roleLevel: number | null | undefined): boolean;
12
+ /** 从带 roleLevel 的用户对象读门控。 */
13
+ export declare function isAdminUser(user: RoleAware | null | undefined): boolean;
14
+ export declare function isSuperAdminUser(user: RoleAware | null | undefined): boolean;
package/dist/roles.js ADDED
@@ -0,0 +1,18 @@
1
+ function e(n) {
2
+ return (n ?? 0) >= 1;
3
+ }
4
+ function r(n) {
5
+ return (n ?? 0) >= 2;
6
+ }
7
+ function i(n) {
8
+ return e(n?.roleLevel);
9
+ }
10
+ function u(n) {
11
+ return r(n?.roleLevel);
12
+ }
13
+ export {
14
+ e as isAdmin,
15
+ i as isAdminUser,
16
+ r as isSuperAdmin,
17
+ u as isSuperAdminUser
18
+ };
@@ -0,0 +1,17 @@
1
+ import type { RoleAware } from './types';
2
+ export declare function defineAuthUserStore<U extends RoleAware>(storeId: string, createInitialUser: () => U): import("pinia").StoreDefinition<string, {
3
+ userInfo: U;
4
+ }, {
5
+ isAdmin: (state: {
6
+ userInfo: import("vue").UnwrapRef<U>;
7
+ } & import("pinia").PiniaCustomStateProperties<{
8
+ userInfo: U;
9
+ }>) => boolean;
10
+ isSuperAdmin: (state: {
11
+ userInfo: import("vue").UnwrapRef<U>;
12
+ } & import("pinia").PiniaCustomStateProperties<{
13
+ userInfo: U;
14
+ }>) => boolean;
15
+ }, {
16
+ setUserInfo(data: U): void;
17
+ }>;
package/dist/store.js ADDED
@@ -0,0 +1,22 @@
1
+ import { defineStore as i } from "pinia";
2
+ import { isSuperAdmin as n, isAdmin as s } from "./roles.js";
3
+ function u(r, o) {
4
+ return i(r, {
5
+ state: () => ({
6
+ userInfo: o()
7
+ }),
8
+ getters: {
9
+ // 仅用于前端 UI 门控(隐藏修改类按钮/入口);后端走 @PreAuthorize 独立校验,不依赖此值。
10
+ isAdmin: (e) => s(e.userInfo.roleLevel),
11
+ isSuperAdmin: (e) => n(e.userInfo.roleLevel)
12
+ },
13
+ actions: {
14
+ setUserInfo(e) {
15
+ this.userInfo = e;
16
+ }
17
+ }
18
+ });
19
+ }
20
+ export {
21
+ u as defineAuthUserStore
22
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Token 存取 + JWT 解析。完全通用,不含任何业务/路由耦合。
3
+ *
4
+ * 默认用 localStorage,key 为 LF_TOKEN / LF_USERNAME(与 LightFlow 既有一致,
5
+ * 迁移后老会话不丢)。需要隔离多套系统时,用 createTokenStore 传不同 key。
6
+ */
7
+ /** 解析 JWT payload,失败返回 null。 */
8
+ export declare function parseJwtPayload(token: string): Record<string, unknown> | null;
9
+ export interface TokenStoreOptions {
10
+ /** token 存储 key,默认 LF_TOKEN */
11
+ tokenKey?: string;
12
+ /** 用户名存储 key,默认 LF_USERNAME */
13
+ usernameKey?: string;
14
+ /** 存储介质,默认 localStorage */
15
+ storage?: Storage;
16
+ }
17
+ export interface TokenStore {
18
+ setToken(token: string): void;
19
+ /** 取 token;已过期则自动清除并返回 null,避免带死 token 请求后端。 */
20
+ getToken(): string | null;
21
+ removeToken(): void;
22
+ clearAuth(): void;
23
+ isLogin(): boolean;
24
+ getUsername(): string;
25
+ setUsername(name: string): void;
26
+ }
27
+ /** 创建一个带独立 key 的 token 存取器。 */
28
+ export declare function createTokenStore(options?: TokenStoreOptions): TokenStore;
29
+ /** 默认 token 存取器(LF_TOKEN / LF_USERNAME + localStorage)。 */
30
+ export declare const defaultTokenStore: TokenStore;
31
+ export declare const setToken: (token: string) => void;
32
+ export declare const getToken: () => string | null;
33
+ export declare const removeToken: () => void;
34
+ export declare const clearAuth: () => void;
35
+ export declare const isLogin: () => boolean;
36
+ export declare const getUsername: () => string;
package/dist/token.js ADDED
@@ -0,0 +1,65 @@
1
+ function m(e) {
2
+ try {
3
+ const t = e.split(".");
4
+ if (t.length < 2) return null;
5
+ const o = t[1];
6
+ if (!o) return null;
7
+ const n = o.replace(/-/g, "+").replace(/_/g, "/"), c = n + "=".repeat((4 - n.length % 4) % 4), a = decodeURIComponent(
8
+ atob(c).split("").map((l) => "%" + l.charCodeAt(0).toString(16).padStart(2, "0")).join("")
9
+ );
10
+ return JSON.parse(a);
11
+ } catch {
12
+ return null;
13
+ }
14
+ }
15
+ function g() {
16
+ const e = /* @__PURE__ */ new Map();
17
+ return {
18
+ get length() {
19
+ return e.size;
20
+ },
21
+ clear: () => e.clear(),
22
+ getItem: (t) => e.get(t) ?? null,
23
+ key: (t) => Array.from(e.keys())[t] ?? null,
24
+ removeItem: (t) => {
25
+ e.delete(t);
26
+ },
27
+ setItem: (t, o) => {
28
+ e.set(t, o);
29
+ }
30
+ };
31
+ }
32
+ function i(e) {
33
+ return e || (typeof globalThis.localStorage < "u" ? globalThis.localStorage : g());
34
+ }
35
+ function k(e = {}) {
36
+ const t = e.tokenKey ?? "LF_TOKEN", o = e.usernameKey ?? "LF_USERNAME", n = i(e.storage), c = (r) => n.setItem(t, r), a = () => {
37
+ const r = n.getItem(t);
38
+ if (!r) return null;
39
+ const u = m(r);
40
+ return u && typeof u.exp == "number" && u.exp * 1e3 < Date.now() ? (n.removeItem(t), n.removeItem(o), null) : r;
41
+ }, l = () => {
42
+ n.removeItem(t), n.removeItem(o);
43
+ };
44
+ return {
45
+ setToken: c,
46
+ getToken: a,
47
+ removeToken: l,
48
+ clearAuth: l,
49
+ isLogin: () => !!a(),
50
+ getUsername: () => n.getItem(o) || "",
51
+ setUsername: (r) => n.setItem(o, r)
52
+ };
53
+ }
54
+ const s = k(), p = s.setToken, T = s.getToken, f = s.removeToken, d = s.clearAuth, y = s.isLogin, I = s.getUsername;
55
+ export {
56
+ d as clearAuth,
57
+ k as createTokenStore,
58
+ s as defaultTokenStore,
59
+ T as getToken,
60
+ I as getUsername,
61
+ y as isLogin,
62
+ m as parseJwtPayload,
63
+ f as removeToken,
64
+ p as setToken
65
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * auth-vue 的公共类型。
3
+ *
4
+ * 与后端 feishu-auth-spring-boot-starter 的 HTTP 契约对齐:
5
+ * - ApiEnvelope 对应后端 ApiResponse {code,data,msg}
6
+ * - FsLoginResult 对应 FsLoginResponse(顶层带 token)
7
+ * - AuthUserInfo 对应 UserInfoResponse(/getFsInfo 返回)
8
+ */
9
+ /** 通用接口返回信封 {code,data,msg}。 */
10
+ export interface ApiEnvelope<T = unknown> {
11
+ code: number;
12
+ data: T;
13
+ msg: string;
14
+ }
15
+ /** 飞书登录返回壳:除信封外顶层再带一个 token,方便登录页直接读取。 */
16
+ export interface FsLoginResult {
17
+ code?: number;
18
+ data?: unknown;
19
+ msg?: string;
20
+ token?: string;
21
+ }
22
+ /**
23
+ * 当前登录用户信息(库的稳定契约,最小集)。
24
+ * roleLevel:0 普通用户 / 1 管理员 / 2 超管,前端据此做 UI 门控(后端独立校验)。
25
+ *
26
+ * 使用方可以用 TS 交叉类型在此基础上扩展自己的字段。
27
+ */
28
+ export interface AuthUserInfo {
29
+ id: number | null;
30
+ userId: number | null;
31
+ name: string | null;
32
+ unionId: string | null;
33
+ avatar: string | null;
34
+ employeeNo: string | null;
35
+ isExist: boolean | null;
36
+ roleLevel: number | null;
37
+ }
38
+ /** 任何带 roleLevel 的对象都能用角色助手做门控。 */
39
+ export interface RoleAware {
40
+ roleLevel?: number | null;
41
+ }
42
+ /**
43
+ * 包内对「请求实例」的最小依赖。
44
+ *
45
+ * 约定响应拦截器已把 AxiosResponse 解包成 body(与 LightFlow 的 request 实例一致),
46
+ * 所以方法返回 Promise<T>(T=body)而非 Promise<AxiosResponse<T>>。
47
+ */
48
+ export interface AuthRequest {
49
+ get<T = unknown>(url: string, config?: unknown): Promise<T>;
50
+ post<T = unknown>(url: string, data?: unknown, config?: unknown): Promise<T>;
51
+ }
52
+ /** 登录流程状态(供登录页 UI 用)。 */
53
+ export type LoginStatus = 'idle' | 'processing' | 'success' | 'error';
54
+ /** 登录流程提示信息。 */
55
+ export interface LoginTip {
56
+ message: string;
57
+ type: 'info' | 'success' | 'error';
58
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * 飞书登录 Composable:处理「拿授权链接 → 跳飞书 → code 换 token」整条 OAuth 链路,
3
+ * 含飞书回调把 query 冲掉时的 redirect 暂存/回收。与具体 UI 无关,登录页用 status/tip 渲染即可。
4
+ *
5
+ * 依赖通过 options 注入(api / tokens),所以不绑定任何具体 app 的请求实例或 token 实现。
6
+ */
7
+ import { ref } from 'vue';
8
+ import type { AuthApi } from './api';
9
+ import type { TokenStore } from './token';
10
+ import type { LoginStatus, LoginTip } from './types';
11
+ export interface UseFeishuLoginOptions {
12
+ api: Pick<AuthApi, 'fsLogin' | 'getAuthorizeUrl'>;
13
+ tokens: Pick<TokenStore, 'setToken' | 'getToken' | 'clearAuth'>;
14
+ /** 登录成功后无 redirect 时的兜底跳转,默认 /。 */
15
+ defaultRedirect?: string;
16
+ /** 暂存 redirect 的 sessionStorage key,默认 lf:postLoginRedirect。 */
17
+ stashKey?: string;
18
+ }
19
+ export interface UseFeishuLoginReturn {
20
+ status: ReturnType<typeof ref<LoginStatus>>;
21
+ tip: ReturnType<typeof ref<LoginTip>>;
22
+ }
23
+ export declare function useFeishuLogin(options: UseFeishuLoginOptions): {
24
+ status: import("vue").Ref<LoginStatus, LoginStatus>;
25
+ tip: import("vue").Ref<{
26
+ message: string;
27
+ type: "info" | "success" | "error";
28
+ }, LoginTip | {
29
+ message: string;
30
+ type: "info" | "success" | "error";
31
+ }>;
32
+ };
@@ -0,0 +1,80 @@
1
+ import { ref as g, watch as q } from "vue";
2
+ import { useRouter as S, useRoute as w } from "vue-router";
3
+ import { extractLoginToken as L } from "./api.js";
4
+ import { normalizeSafeRedirect as d } from "./redirect.js";
5
+ import { isSuccessEnvelope as m } from "./response.js";
6
+ function E(s) {
7
+ const l = S(), n = w(), i = s.stashKey ?? "lf:postLoginRedirect", a = s.defaultRedirect ?? "/", t = g("idle"), o = g({ message: "正在跳转到飞书授权...", type: "info" });
8
+ function y(e) {
9
+ const r = d(e, "", { stripCode: !1 });
10
+ if (r)
11
+ try {
12
+ sessionStorage.setItem(i, r);
13
+ } catch {
14
+ }
15
+ }
16
+ function f() {
17
+ try {
18
+ const e = sessionStorage.getItem(i);
19
+ return sessionStorage.removeItem(i), e;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ async function h(e) {
25
+ t.value = "processing", o.value = { message: "正在登录...", type: "info" };
26
+ try {
27
+ const r = await s.api.fsLogin(e), c = L(r);
28
+ if (m(r) && c) {
29
+ s.tokens.clearAuth(), s.tokens.setToken(c), t.value = "success", o.value = { message: "登录成功", type: "success" };
30
+ const u = n.query.redirect, R = f(), k = d(u || R || a, a);
31
+ setTimeout(() => {
32
+ window.location.href = k;
33
+ }, 200);
34
+ } else
35
+ t.value = "error", o.value = { message: `登录失败: ${r?.msg || "未知错误"}`, type: "error" };
36
+ } catch (r) {
37
+ t.value = "error";
38
+ const c = r instanceof Error ? r.message : "登录失败,请重试";
39
+ o.value = { message: c, type: "error" };
40
+ }
41
+ }
42
+ async function p() {
43
+ t.value = "processing";
44
+ try {
45
+ y(n.query.redirect);
46
+ const e = await s.api.getAuthorizeUrl(), r = e?.data;
47
+ m(e) && r ? window.location.href = r : (t.value = "error", o.value = { message: `获取授权链接失败: ${e?.msg || "未知错误"}`, type: "error" });
48
+ } catch {
49
+ t.value = "error", o.value = { message: "获取授权链接失败,请重试", type: "error" };
50
+ }
51
+ }
52
+ function v() {
53
+ if (s.tokens.getToken()) {
54
+ const e = n.query.redirect, r = f(), c = e || r;
55
+ if (c) {
56
+ const u = d(c, a);
57
+ l.replace(u);
58
+ } else
59
+ l.replace(a);
60
+ return !0;
61
+ }
62
+ return !1;
63
+ }
64
+ return q(
65
+ () => [n.query.code, n.query.redirect],
66
+ () => {
67
+ if (t.value === "processing" || t.value === "success") return;
68
+ const e = n.query.code;
69
+ if (typeof e == "string" && e) {
70
+ h(e);
71
+ return;
72
+ }
73
+ v() || p();
74
+ },
75
+ { immediate: !0 }
76
+ ), { status: t, tip: o };
77
+ }
78
+ export {
79
+ E as useFeishuLogin
80
+ };
@@ -0,0 +1,12 @@
1
+ import type { AuthApi } from './api';
2
+ import type { TokenStore } from './token';
3
+ export interface UseUserSwitchOptions {
4
+ api: Pick<AuthApi, 'devSwitchUser'>;
5
+ tokens: Pick<TokenStore, 'setToken'>;
6
+ /** 切号成功后的回调,默认整页刷新 location.reload()。 */
7
+ onSwitched?: (token: string) => void;
8
+ }
9
+ export declare function useUserSwitch(options: UseUserSwitchOptions): {
10
+ switching: import("vue").Ref<boolean, boolean>;
11
+ switchTo: (unionId: string) => Promise<boolean>;
12
+ };
@@ -0,0 +1,19 @@
1
+ import { ref as a } from "vue";
2
+ import { extractLoginToken as c } from "./api.js";
3
+ function l(e) {
4
+ const t = a(!1);
5
+ async function i(n) {
6
+ if (!n) return !1;
7
+ t.value = !0;
8
+ try {
9
+ const o = await e.api.devSwitchUser(n), r = c(o);
10
+ return r ? (e.tokens.setToken(r), e.onSwitched ? e.onSwitched(r) : window.location.reload(), !0) : !1;
11
+ } finally {
12
+ t.value = !1;
13
+ }
14
+ }
15
+ return { switching: t, switchTo: i };
16
+ }
17
+ export {
18
+ l as useUserSwitch
19
+ };
package/package.json ADDED
@@ -0,0 +1,97 @@
1
+ {
2
+ "name": "@czxingyu/feishu-auth-vue",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "飞书登录 + JWT + 三角色 RBAC 的 Vue 前端 npm 包,与 feishu-auth-spring-boot-starter 配套",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./api": {
15
+ "types": "./dist/api.d.ts",
16
+ "import": "./dist/api.js"
17
+ },
18
+ "./guard": {
19
+ "types": "./dist/guard.d.ts",
20
+ "import": "./dist/guard.js"
21
+ },
22
+ "./http": {
23
+ "types": "./dist/http.d.ts",
24
+ "import": "./dist/http.js"
25
+ },
26
+ "./roles": {
27
+ "types": "./dist/roles.d.ts",
28
+ "import": "./dist/roles.js"
29
+ },
30
+ "./redirect": {
31
+ "types": "./dist/redirect.d.ts",
32
+ "import": "./dist/redirect.js"
33
+ },
34
+ "./response": {
35
+ "types": "./dist/response.d.ts",
36
+ "import": "./dist/response.js"
37
+ },
38
+ "./store": {
39
+ "types": "./dist/store.d.ts",
40
+ "import": "./dist/store.js"
41
+ },
42
+ "./token": {
43
+ "types": "./dist/token.d.ts",
44
+ "import": "./dist/token.js"
45
+ },
46
+ "./useFeishuLogin": {
47
+ "types": "./dist/useFeishuLogin.d.ts",
48
+ "import": "./dist/useFeishuLogin.js"
49
+ },
50
+ "./useUserSwitch": {
51
+ "types": "./dist/useUserSwitch.d.ts",
52
+ "import": "./dist/useUserSwitch.js"
53
+ },
54
+ "./package.json": {
55
+ "default": "./package.json"
56
+ }
57
+ },
58
+ "module": "./dist/index.js",
59
+ "files": [
60
+ "dist",
61
+ "README.md"
62
+ ],
63
+ "sideEffects": false,
64
+ "publishConfig": {
65
+ "access": "public"
66
+ },
67
+ "scripts": {
68
+ "build": "vite build && tsc -p tsconfig.build.json",
69
+ "prepack": "npm run build",
70
+ "test": "vitest run",
71
+ "type-check": "vue-tsc --noEmit"
72
+ },
73
+ "peerDependencies": {
74
+ "axios": "^1.0.0",
75
+ "pinia": "^2.0.0 || ^3.0.0",
76
+ "vue": "^3.4.0",
77
+ "vue-router": "^4.0.0"
78
+ },
79
+ "peerDependenciesMeta": {
80
+ "axios": {
81
+ "optional": true
82
+ }
83
+ },
84
+ "devDependencies": {
85
+ "@vitejs/plugin-vue": "^6.0.1",
86
+ "@vue/tsconfig": "^0.8.1",
87
+ "axios": "^1.13.6",
88
+ "jsdom": "^27.3.0",
89
+ "pinia": "^3.0.3",
90
+ "typescript": "~5.9.0",
91
+ "vite": "^7.1.11",
92
+ "vitest": "^4.0.16",
93
+ "vue": "^3.5.22",
94
+ "vue-router": "^4.6.3",
95
+ "vue-tsc": "^3.1.1"
96
+ }
97
+ }