feat: integrate Jinxunda web with portal-bound SSO
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
# 不匹配 pc-client/src/components 等源码子目录,保证其可入库
|
||||
/components/
|
||||
.selftest/
|
||||
pc-client/dist-portal/
|
||||
data/
|
||||
**/agconnect-services.json
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY server.js admin.html ./
|
||||
COPY server.js portal-sso.js admin.html ./
|
||||
ENV PORT=10010 DATA_FILE=/app/data/employees.json
|
||||
EXPOSE 10010
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const digest = s => crypto.createHash('sha256').update(s).digest('hex');
|
||||
const hex = s => typeof s === 'string' && /^[a-f0-9]{64}$/.test(s);
|
||||
|
||||
// Browser-bound portal tickets. OpenIM user IDs are permanent empids, not mutable staff numbers.
|
||||
module.exports = function createPortalBridge(deps, env = process.env) {
|
||||
const enabled = env.PORTAL_SSO_ENABLED === '1';
|
||||
if (!enabled) return { handle: async () => false };
|
||||
const portal = new URL(env.PORTAL_PUBLIC_URL);
|
||||
const internal = new URL(env.PORTAL_INTERNAL_URL);
|
||||
const secret = env.PORTAL_SSO_SECRET || '';
|
||||
if (!['http:', 'https:'].includes(internal.protocol) || portal.protocol !== 'https:' || secret.length < 32)
|
||||
throw new Error('Invalid portal configuration');
|
||||
const file = path.join(path.dirname(env.DATA_FILE), 'portal-sessions.json');
|
||||
let sessions = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {};
|
||||
const cookieFlags = '; HttpOnly; Secure; SameSite=Lax; Path=/';
|
||||
const cookieName = '__Host-jxd_portal_sid';
|
||||
const save = () => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file + '.tmp', JSON.stringify(sessions), { mode: 0o600 });
|
||||
fs.renameSync(file + '.tmp', file);
|
||||
};
|
||||
const cookies = req => Object.fromEntries(String(req.headers.cookie || '').split(';').map(p => {
|
||||
const n = p.indexOf('='); return [p.slice(0, n).trim(), p.slice(n + 1)];
|
||||
}));
|
||||
async function call(operation, payload) {
|
||||
const response = await fetch(new URL('/api/sso/apps/jinxunda/' + operation, internal), {
|
||||
method: 'POST', redirect: 'error', signal: AbortSignal.timeout(5000),
|
||||
headers: { Authorization: 'Bearer ' + secret, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error('Portal unavailable');
|
||||
return response.json();
|
||||
}
|
||||
function reply(res, status, value) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' });
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
function redirect(res, location, cookie) {
|
||||
res.writeHead(303, { Location: location, 'Set-Cookie': cookie,
|
||||
'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' }); res.end();
|
||||
}
|
||||
async function valid(row) {
|
||||
const r = await call('introspect', { session_handle: row.handle });
|
||||
return r.active && r.identity?.empid === row.empid && r.identity?.local_uid === row.localUid;
|
||||
}
|
||||
async function revoke(key) {
|
||||
const row = sessions[key];
|
||||
if (!row) return;
|
||||
row.revoked = true;
|
||||
save();
|
||||
// Persist revocation before kicking; failed kicks are retried by the sweep.
|
||||
const token = await deps.getAdminToken();
|
||||
const result = await deps.imApi('/auth/force_logout', { platformID: 5, userID: row.imUid }, token);
|
||||
if (result.errCode !== 0) throw new Error('IM revocation pending');
|
||||
delete sessions[key]; save();
|
||||
}
|
||||
let sweeping = false;
|
||||
const timer = setInterval(async () => {
|
||||
if (sweeping) return;
|
||||
sweeping = true;
|
||||
try {
|
||||
for (const [key, row] of Object.entries(sessions)) {
|
||||
let active = false;
|
||||
try { active = !row.revoked && await valid(row); } catch { /* fail closed */ }
|
||||
if (!active) { try { await revoke(key); } catch { /* retry next sweep */ } }
|
||||
}
|
||||
} finally { sweeping = false; }
|
||||
}, 10000);
|
||||
timer.unref();
|
||||
return {
|
||||
close: () => clearInterval(timer),
|
||||
async handle(req, res) {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const route = url.pathname;
|
||||
if (route === '/portal-sso') {
|
||||
if (req.method !== 'GET') { reply(res, 405, { message: 'Method not allowed' }); return true; }
|
||||
try {
|
||||
const ticket = url.searchParams.get('ticket');
|
||||
if (!ticket) {
|
||||
const verifier = crypto.randomBytes(32).toString('hex');
|
||||
const theme = url.searchParams.get('theme') === 'night' ? 'night' : 'day';
|
||||
const value = [verifier, Date.now(), theme].join('.');
|
||||
redirect(res, new URL('/api/sso/apps/jinxunda/authorize?challenge=' + digest(verifier), portal).href,
|
||||
'__Host-jxd_proof=' + value + cookieFlags + '; Max-Age=120');
|
||||
return true;
|
||||
}
|
||||
const [verifier, timestamp, theme] = (cookies(req)['__Host-jxd_proof'] || '').split('.');
|
||||
if (!hex(ticket) || !hex(verifier) || !Number.isFinite(Number(timestamp))
|
||||
|| Date.now() - Number(timestamp) > 120000 || Number(timestamp) > Date.now())
|
||||
throw new Error('Invalid browser proof');
|
||||
const result = await call('exchange', { ticket, verifier });
|
||||
const who = result.identity;
|
||||
if (!hex(result.session_handle) || !/^[a-f0-9-]{36}$/.test(who?.empid || '')
|
||||
|| !Number.isSafeInteger(who?.local_uid) || who.local_uid < 1)
|
||||
throw new Error('Invalid identity');
|
||||
// OpenIM rejects UUID hyphens. Preserve empid and derive its lossless 32-hex representation.
|
||||
const imUid = who.empid.replace(/-/g, '');
|
||||
if (!/^[a-f0-9]{32}$/.test(imUid)) throw new Error('Invalid permanent identity');
|
||||
if (!await deps.ensureImUser(imUid, who.display_name)) throw new Error('IM unavailable');
|
||||
const token = await deps.getImUserToken(imUid, 5);
|
||||
const sid = crypto.randomBytes(32).toString('hex');
|
||||
sessions[digest(sid)] = { handle: result.session_handle, empid: who.empid, imUid,
|
||||
localUid: who.local_uid, staffNo: who.username, name: who.display_name,
|
||||
token: token.token, theme: theme === 'night' ? 'night' : 'day' };
|
||||
save();
|
||||
redirect(res, '/#/chat', [cookieName + '=' + sid + cookieFlags + '; Max-Age=28800',
|
||||
'__Host-jxd_proof=' + cookieFlags + '; Max-Age=0']);
|
||||
} catch (error) {
|
||||
console.error('Portal SSO failed:', String(error.message).replace(/[a-f0-9]{32,}/gi, '[redacted]').slice(0, 180));
|
||||
reply(res, 401, { message: '统一登录未完成,请返回门户重新进入。', portal: portal.origin });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (route === '/api/portal/session') {
|
||||
if (req.method !== 'GET') { reply(res, 405, {}); return true; }
|
||||
const sid = cookies(req)[cookieName];
|
||||
const key = hex(sid) ? digest(sid) : '';
|
||||
const row = sessions[key];
|
||||
try {
|
||||
if (!row || row.revoked || !await valid(row)) throw new Error('Invalid session');
|
||||
reply(res, 200, { userID: row.imUid, imToken: row.token, chatToken: row.token,
|
||||
displayName: row.name, staffNo: row.staffNo, theme: row.theme, portal: portal.origin });
|
||||
} catch {
|
||||
if (row) { try { await revoke(key); } catch { /* retry */ }
|
||||
}
|
||||
reply(res, 401, { message: '请从统一门户进入金讯达', portal: portal.origin });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// The existing app must not retain a parallel password/admin entry.
|
||||
if (route === '/api/login' || route === '/' || route === '/admin' || route.startsWith('/api/admin/')) {
|
||||
reply(res, 403, { message: '账号及登录统一由门户管理', portal: portal.origin }); return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -357,8 +357,10 @@ const routes = {
|
||||
},
|
||||
};
|
||||
|
||||
const portalBridge = require('./portal-sso')({ getAdminToken, imApi, ensureImUser, getImUserToken });
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
if (await portalBridge.handle(req, res)) return;
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
req.urlObj = url;
|
||||
if (req.method === 'OPTIONS') return send(res, 204, {});
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const createBridge = require('./portal-sso');
|
||||
|
||||
test('portal proof, exchange, active session, revocation, and legacy login closure', async () => {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'jinniu-sso-test-'));
|
||||
let active = true, exchanges = 0, kicks = 0;
|
||||
const identity = { empid: 'fefafefa-1234-1234-1234-123456789abc', local_uid: 1, username: '019999', display_name: '管理员' };
|
||||
const portal = http.createServer(async (req, res) => {
|
||||
assert.equal(req.headers.authorization, 'Bearer ' + 's'.repeat(32));
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
if (req.url.endsWith('/exchange')) {
|
||||
exchanges++;
|
||||
res.end(JSON.stringify({ identity, session_handle: 'c'.repeat(64) }));
|
||||
} else res.end(JSON.stringify({active, identity}));
|
||||
});
|
||||
await new Promise(r => portal.listen(0,'127.0.0.1',r));
|
||||
const bridge = createBridge({
|
||||
ensureImUser: async (uid) => { assert.equal(uid, identity.empid.replace(/-/g,'')); return true; },
|
||||
getImUserToken: async (uid, pid) => { assert.equal(pid, 5); return {token:'private-im-token'}; },
|
||||
getAdminToken: async () => 'private-admin-token',
|
||||
imApi: async (route, body) => { assert.equal(body.userID, identity.empid.replace(/-/g,'')); kicks++; return {errCode:0}; },
|
||||
}, {PORTAL_SSO_ENABLED:'1', PORTAL_SSO_SECRET:'s'.repeat(32), PORTAL_PUBLIC_URL:'https://bg.example.test',
|
||||
PORTAL_INTERNAL_URL:'http://127.0.0.1:'+portal.address().port, DATA_FILE:path.join(temp,'employees.json')});
|
||||
const server = http.createServer(async (req,res) => { if (!await bridge.handle(req,res)) { res.writeHead(404);res.end(); } });
|
||||
await new Promise(r => server.listen(0,'127.0.0.1',r));
|
||||
const base = 'http://127.0.0.1:'+server.address().port;
|
||||
try {
|
||||
assert.equal((await fetch(base+'/api/login',{method:'POST'})).status,403);
|
||||
assert.equal((await fetch(base+'/api/admin/employees')).status,403);
|
||||
assert.equal((await fetch(base+'/api/portal/session')).status,401);
|
||||
assert.equal((await fetch(base+'/portal-sso?ticket='+'a'.repeat(64))).status,401);
|
||||
assert.equal(exchanges,0);
|
||||
const start = await fetch(base+'/portal-sso?theme=night',{redirect:'manual'});
|
||||
assert.equal(start.status,303);
|
||||
assert.match(start.headers.get('location'),/^https:\/\/bg\.example\.test\/api\/sso\/apps\/jinxunda\/authorize\?challenge=[a-f0-9]{64}$/);
|
||||
const proof = start.headers.get('set-cookie').split(';')[0];
|
||||
const finish = await fetch(base+'/portal-sso?ticket='+'a'.repeat(64),{redirect:'manual',headers:{cookie:proof}});
|
||||
assert.equal(finish.status,303);
|
||||
const cookie = finish.headers.getSetCookie()[0].split(';')[0];
|
||||
assert.match(finish.headers.getSetCookie()[0],/HttpOnly; Secure; SameSite=Lax/);
|
||||
let r = await fetch(base+'/api/portal/session',{headers:{cookie}});
|
||||
assert.equal(r.headers.get('cache-control'),'no-store');
|
||||
const profile=await r.json();
|
||||
assert.equal(profile.userID,identity.empid.replace(/-/g,''));
|
||||
assert.equal(profile.staffNo,'019999');
|
||||
assert.equal(profile.theme,'night');
|
||||
active=false;
|
||||
r = await fetch(base+'/api/portal/session',{headers:{cookie}});
|
||||
assert.equal(r.status,401);
|
||||
assert.equal(kicks,1);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(temp,'portal-sessions.json'))),{});
|
||||
} finally {
|
||||
bridge.close();
|
||||
await new Promise(r=>server.close(r));
|
||||
await new Promise(r=>portal.close(r));
|
||||
assert.ok(path.resolve(temp).startsWith(path.resolve(os.tmpdir())+path.sep));
|
||||
fs.rmSync(temp,{recursive:true});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
# 金讯达统一门户网页试运行
|
||||
|
||||
网页地址 https://jxd.jinniu.ink,唯一身份入口 https://bg.jinniu.ink。
|
||||
|
||||
account-service 设置 PORTAL_SSO_ENABLED=1、PORTAL_PUBLIC_URL、PORTAL_INTERNAL_URL 和独立不少于32字符的 PORTAL_SSO_SECRET。该密钥必须与门户 JINXUNDA_SSO_SECRET 一致。现有 OpenIM 管理服务密钥仍通过私有环境文件提供。
|
||||
|
||||
`/portal-sso` 使用浏览器 verifier 绑定的短期一次性票据。身份使用永久 empid,OpenIM UID 为去掉 UUID 连字符的32位十六进制表示。不能用工号改名重新创建聊天身份。
|
||||
`/api/portal/session` 同源且禁止缓存;每次验证门户会话。后台每10秒撤销失效用户平台5会话,失败会重试。会话文件仅服务端可读,原口令登录和管理API在门户模式禁用。
|
||||
|
||||
构建 pc-client 网页版本时配置:
|
||||
|
||||
```
|
||||
VITE_PORTAL_MODE=1
|
||||
VITE_PORTAL_URL=https://bg.jinniu.ink
|
||||
VITE_API_URL=https://jxd.jinniu.ink
|
||||
VITE_WS_URL=wss://jxd.jinniu.ink/msg_gateway
|
||||
VITE_ACCOUNT_URL=https://jxd.jinniu.ink/account
|
||||
VITE_LIVEKIT_URL=wss://jxd.jinniu.ink/livekit/
|
||||
```
|
||||
|
||||
执行 `npx vite build --config vite.portal.config.ts`,输出 dist-portal。Nginx 将根路径/资源指向该目录,SSO及会话接口代理账号服务;原OpenIM、对象存储、LiveKit路由保留。
|
||||
|
||||
验证:`node --test test-portal-sso.js test-admin-page.js`(在account-service目录);真实HTTPS兑换OpenIM凭证、浏览器进入聊天、返回门户、退出撤销已验收。
|
||||
仅保留一个正式管理员,因此双人消息、音视频、附件尚未完成真实员工验收。原生Windows/Android客户端仍为历史登录机制,需要另行适配门户登录;不能视为本次网页交付已覆盖。
|
||||
@@ -25,6 +25,7 @@ import { OverlayVisibleHandle } from "../../hooks/useOverlayVisible";
|
||||
import About from "./About";
|
||||
import styles from "./left-nav-bar.module.scss";
|
||||
import PersonalSettings from "./PersonalSettings";
|
||||
import { portalMode } from '@/portal';
|
||||
|
||||
const AppIcon = () => (
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-[var(--primary)]">
|
||||
@@ -281,7 +282,7 @@ const LeftNavBar = memo(() => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Popover
|
||||
{!portalMode && <Popover
|
||||
content={ProfileContent}
|
||||
trigger="click"
|
||||
placement="rightBottom"
|
||||
@@ -297,7 +298,7 @@ const LeftNavBar = memo(() => {
|
||||
src={selfInfo.faceURL}
|
||||
text={selfInfo.nickname}
|
||||
/>
|
||||
</Popover>
|
||||
</Popover>}
|
||||
<PersonalSettings ref={personalSettingsRef} />
|
||||
<About ref={aboutRef} />
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useUserStore } from "@/store";
|
||||
import GlobalModals from "./GlobalModals";
|
||||
import LeftNavBar from "./LeftNavBar";
|
||||
import { useGlobalEvent } from "./useGlobalEvents";
|
||||
import { portalMode, portalHome } from '@/portal';
|
||||
|
||||
export const MainContentLayout = () => {
|
||||
useGlobalEvent();
|
||||
@@ -35,7 +36,8 @@ export const MainContentLayout = () => {
|
||||
|
||||
return (
|
||||
<Spin className="!max-h-none" spinning={showLockLoading} tip={loadingTip}>
|
||||
<Layout className="relative h-full flex-row">
|
||||
{portalMode && <header className="portal-header"><strong>金讯达</strong><span>金牛集团 · 内部通讯</span><a href={portalHome + '?theme=' + document.documentElement.dataset.theme}>返回统一门户</a></header>}
|
||||
<Layout className="relative h-full flex-row" style={portalMode ? {height: 'calc(100% - 56px)'} : undefined}>
|
||||
<LeftNavBar />
|
||||
<Outlet />
|
||||
{/* 悬浮窗口控制条:无边框窗口的拖动区 + 最小化/关闭 */}
|
||||
|
||||
+19
-1
@@ -7,6 +7,8 @@ import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import { isSaveLog } from "./config";
|
||||
import { initializePortal, portalHome, portalMode } from './portal';
|
||||
import './portal.scss';
|
||||
|
||||
if (window.electronAPI && isSaveLog) {
|
||||
const sdkLogger = log.scope("openim-sdk-core");
|
||||
@@ -16,6 +18,22 @@ if (window.electronAPI && isSaveLog) {
|
||||
console.error = rendererLogger.error.bind(rendererLogger);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<App />);
|
||||
initializePortal().then(ready => {
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
root.render(ready ? <App /> : <main style={{padding: 48}}><h1>金讯达</h1>
|
||||
<p>请从统一门户登录并进入金讯达。</p><a href={portalHome}>返回统一门户</a></main>);
|
||||
if (ready && portalMode) {
|
||||
let checking = false;
|
||||
setInterval(async () => {
|
||||
if (checking) return;
|
||||
checking = true;
|
||||
try {
|
||||
const response = await fetch('/api/portal/session', { cache: 'no-store' });
|
||||
if (!response.ok) window.location.replace(portalHome);
|
||||
} catch { window.location.replace(portalHome); }
|
||||
finally { checking = false; }
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
|
||||
postMessage({ payload: "removeLoading" }, "*");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Layout } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { portalMode } from "@/portal";
|
||||
|
||||
export const EmptyChat = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,7 +16,7 @@ export const EmptyChat = () => {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="mt-4 text-base font-medium">{t("placeholder.title")}</div>
|
||||
<div className="mt-4 text-base font-medium">{portalMode ? '金讯达' : t("placeholder.title")}</div>
|
||||
<div className="mt-1 text-xs text-[var(--sub-text)]">
|
||||
{t("placeholder.subTitle")}
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,13 @@ import { feedbackToast } from "@/utils/common";
|
||||
|
||||
import styles from "./index.module.scss";
|
||||
import LoginForm from "./LoginForm";
|
||||
import { portalMode, portalHome } from '@/portal';
|
||||
|
||||
export const Login = () => {
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
if (portalMode) return <main style={{padding: 48}}><p>登录已失效,请返回门户重新进入。</p><a href={portalHome}>返回统一门户</a></main>;
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(`${`${APP_NAME} ${APP_VERSION}`}/${SDK_VERSION}`);
|
||||
feedbackToast({ msg: t("toast.copySuccess") });
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
html[data-portal='true'] {
|
||||
--primary: #b32635;
|
||||
--primary-active: #f6e8e9;
|
||||
--top-search-bar: #b32635;
|
||||
--chat-bubble-sender: #f4dfe1;
|
||||
.portal-header { height: 56px; display: flex; align-items: center; gap: 20px; padding: 0 24px; border-bottom: 1px solid var(--gap-text); background: #fff; }
|
||||
.portal-header strong { color: var(--primary); font-size: 20px; }
|
||||
.portal-header span { color: var(--sub-text); }
|
||||
.portal-header a { margin-left: auto; color: var(--primary); }
|
||||
.ant-spin-nested-loading, .ant-spin-container { height: 100%; }
|
||||
&[data-theme='night'] {
|
||||
color-scheme: dark;
|
||||
--primary: #e2737d; --primary-active: #35272a; --base-black: #e9e5df;
|
||||
--sub-text: #a8a29a; --gap-text: #3b3634; --search-bg: #292626;
|
||||
--chat-bg: #201e1d; --chat-bubble: #312d2b; --chat-bubble-sender: #4b3034;
|
||||
background: #201e1d;
|
||||
.portal-header, .bg-white, .ant-layout { background: #252221 !important; }
|
||||
input, textarea, .ant-input, .ant-modal-content, .ant-popover-inner { background: #292626; color: #e9e5df; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { setIMProfile, clearIMProfile } from './utils/storage';
|
||||
export const portalMode = import.meta.env.VITE_PORTAL_MODE === '1';
|
||||
export const portalHome = import.meta.env.VITE_PORTAL_URL || '';
|
||||
|
||||
export async function initializePortal(): Promise<boolean> {
|
||||
if (!portalMode) return true;
|
||||
try {
|
||||
const response = await fetch('/api/portal/session', { cache: 'no-store', credentials: 'same-origin' });
|
||||
if (!response.ok) throw new Error('Session expired');
|
||||
const profile = await response.json();
|
||||
await setIMProfile(profile);
|
||||
document.documentElement.dataset.theme = profile.theme === 'night' ? 'night' : 'day';
|
||||
document.documentElement.dataset.portal = 'true';
|
||||
document.title = '金讯达 · 金牛集团';
|
||||
return true;
|
||||
} catch {
|
||||
clearIMProfile();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,7 @@ export const setIMProfile = ({
|
||||
imToken: string;
|
||||
userID: string;
|
||||
}) => {
|
||||
setTMToken(imToken);
|
||||
setChatToken(chatToken);
|
||||
setTMUserID(userID);
|
||||
return Promise.all([setTMToken(imToken), setChatToken(chatToken), setTMUserID(userID)]);
|
||||
};
|
||||
|
||||
export const setLocale = (locale: string) => localStorage.setItem("IM_LOCALE", locale);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: { alias: { '@': path.resolve(__dirname, 'src') } },
|
||||
build: { outDir: 'dist-portal', sourcemap: false },
|
||||
});
|
||||
Reference in New Issue
Block a user