Multi-account GitHub auth + UI polish (model logos, markdown, scroll behavior) (#219)
* feat: display provider logos for favorite/recent models Show provider logo next to model name in favorites and recents Render provider logos in ModelControls, ModelMultiSelect, and ModelSelector lists Maintain zero-logo state for other sections to avoid clutter * feat: render user message as markdown instead of plain text Render agent mentions as markdown links in user text Apply inside list style for chat content to fix list rendering Rely on SimpleMarkdownRenderer for consistent rendering * fix(openchamber): adjust layout and overscroll behavior Enable overscroll-auto on overlay containers for smoother scrolling Move page content to full-width wrapper and preserve section borders Show AboutSettings inside its own bordered block when visible * feat: integrate GitHub auth status store and UI Introduce GitHubAuthStore to track connection status and polling Show GitHub avatar in header when connected Guard issue/pr dialogs behind GitHub auth status and show notices * feat: add GitHub multi-account support Add API and UI flow to activate a GitHub account Show and switch between multiple GitHub accounts in header Persist and normalize accounts list with current selection
This commit is contained in:
committed by
GitHub
parent
1de0ebd4fc
commit
74511abfda
@@ -4022,15 +4022,16 @@ async function main(options = {}) {
|
||||
|
||||
app.get('/api/github/auth/status', async (_req, res) => {
|
||||
try {
|
||||
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
|
||||
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
||||
const auth = getGitHubAuth();
|
||||
const accounts = getGitHubAuthAccounts();
|
||||
if (!auth?.accessToken) {
|
||||
return res.json({ connected: false });
|
||||
return res.json({ connected: false, accounts });
|
||||
}
|
||||
|
||||
const octokit = getOctokitOrNull();
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false });
|
||||
return res.json({ connected: false, accounts });
|
||||
}
|
||||
|
||||
let user = null;
|
||||
@@ -4039,7 +4040,7 @@ async function main(options = {}) {
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
clearGitHubAuth();
|
||||
return res.json({ connected: false });
|
||||
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4050,6 +4051,7 @@ async function main(options = {}) {
|
||||
connected: true,
|
||||
user: mergedUser,
|
||||
scope: auth.scope,
|
||||
accounts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get GitHub auth status:', error);
|
||||
@@ -4091,7 +4093,7 @@ async function main(options = {}) {
|
||||
|
||||
app.post('/api/github/auth/complete', async (req, res) => {
|
||||
try {
|
||||
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth } = await getGitHubLibraries();
|
||||
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
||||
const clientId = getGitHubClientId();
|
||||
if (!clientId) {
|
||||
return res.status(400).json({
|
||||
@@ -4137,6 +4139,7 @@ async function main(options = {}) {
|
||||
connected: true,
|
||||
user,
|
||||
scope: typeof payload.scope === 'string' ? payload.scope : '',
|
||||
accounts: getGitHubAuthAccounts(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to complete GitHub device flow:', error);
|
||||
@@ -4144,6 +4147,51 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/github/auth/activate', async (req, res) => {
|
||||
try {
|
||||
const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
|
||||
const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : '';
|
||||
if (!accountId) {
|
||||
return res.status(400).json({ error: 'accountId is required' });
|
||||
}
|
||||
const activated = activateGitHubAuth(accountId);
|
||||
if (!activated) {
|
||||
return res.status(404).json({ error: 'GitHub account not found' });
|
||||
}
|
||||
|
||||
const auth = getGitHubAuth();
|
||||
const accounts = getGitHubAuthAccounts();
|
||||
if (!auth?.accessToken) {
|
||||
return res.json({ connected: false, accounts });
|
||||
}
|
||||
|
||||
const octokit = getOctokitOrNull();
|
||||
if (!octokit) {
|
||||
return res.json({ connected: false, accounts });
|
||||
}
|
||||
|
||||
let user = auth.user || null;
|
||||
try {
|
||||
user = await getGitHubUserSummary(octokit);
|
||||
} catch (error) {
|
||||
if (error?.status === 401) {
|
||||
clearGitHubAuth();
|
||||
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
connected: true,
|
||||
user,
|
||||
scope: auth.scope,
|
||||
accounts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to activate GitHub account:', error);
|
||||
return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/github/auth', async (_req, res) => {
|
||||
try {
|
||||
const { clearGitHubAuth } = await getGitHubLibraries();
|
||||
|
||||
@@ -61,58 +61,206 @@ function writeJsonFile(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getGitHubAuth() {
|
||||
const data = readJsonFile();
|
||||
if (!data) {
|
||||
return null;
|
||||
function resolveAccountId({ user, accessToken, accountId }) {
|
||||
if (typeof accountId === 'string' && accountId.trim()) {
|
||||
return accountId.trim();
|
||||
}
|
||||
const accessToken = typeof data.accessToken === 'string' ? data.accessToken : '';
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
if (user && typeof user.login === 'string' && user.login.trim()) {
|
||||
return user.login.trim();
|
||||
}
|
||||
if (user && typeof user.id === 'number') {
|
||||
return String(user.id);
|
||||
}
|
||||
if (typeof accessToken === 'string' && accessToken.trim()) {
|
||||
return `token:${accessToken.slice(0, 8)}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeAuthEntry(entry) {
|
||||
if (!entry || typeof entry !== 'object') return null;
|
||||
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
|
||||
if (!accessToken) return null;
|
||||
const user = entry.user && typeof entry.user === 'object'
|
||||
? {
|
||||
login: typeof entry.user.login === 'string' ? entry.user.login : null,
|
||||
avatarUrl: typeof entry.user.avatarUrl === 'string' ? entry.user.avatarUrl : null,
|
||||
id: typeof entry.user.id === 'number' ? entry.user.id : null,
|
||||
name: typeof entry.user.name === 'string' ? entry.user.name : null,
|
||||
email: typeof entry.user.email === 'string' ? entry.user.email : null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const accountId = resolveAccountId({
|
||||
user,
|
||||
accessToken,
|
||||
accountId: typeof entry.accountId === 'string' ? entry.accountId : '',
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
scope: typeof data.scope === 'string' ? data.scope : '',
|
||||
tokenType: typeof data.tokenType === 'string' ? data.tokenType : 'bearer',
|
||||
createdAt: typeof data.createdAt === 'number' ? data.createdAt : null,
|
||||
user: data.user && typeof data.user === 'object'
|
||||
? {
|
||||
login: typeof data.user.login === 'string' ? data.user.login : null,
|
||||
avatarUrl: typeof data.user.avatarUrl === 'string' ? data.user.avatarUrl : null,
|
||||
id: typeof data.user.id === 'number' ? data.user.id : null,
|
||||
name: typeof data.user.name === 'string' ? data.user.name : null,
|
||||
email: typeof data.user.email === 'string' ? data.user.email : null,
|
||||
}
|
||||
: null,
|
||||
scope: typeof entry.scope === 'string' ? entry.scope : '',
|
||||
tokenType: typeof entry.tokenType === 'string' ? entry.tokenType : 'bearer',
|
||||
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
|
||||
user,
|
||||
current: Boolean(entry.current),
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
export function setGitHubAuth({ accessToken, scope, tokenType, user }) {
|
||||
function normalizeAuthList(raw) {
|
||||
const list = (Array.isArray(raw) ? raw : [raw])
|
||||
.map((entry) => normalizeAuthEntry(entry))
|
||||
.filter(Boolean);
|
||||
|
||||
if (!list.length) {
|
||||
return { list: [], changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
let currentFound = false;
|
||||
list.forEach((entry) => {
|
||||
if (entry.current && !currentFound) {
|
||||
currentFound = true;
|
||||
} else if (entry.current && currentFound) {
|
||||
entry.current = false;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!currentFound && list[0]) {
|
||||
list[0].current = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
list.forEach((entry) => {
|
||||
if (!entry.accountId) {
|
||||
entry.accountId = resolveAccountId(entry);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { list, changed };
|
||||
}
|
||||
|
||||
function readAuthList() {
|
||||
const data = readJsonFile();
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const { list, changed } = normalizeAuthList(data);
|
||||
if (changed) {
|
||||
writeJsonFile(list);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function writeAuthList(list) {
|
||||
writeJsonFile(list);
|
||||
}
|
||||
|
||||
export function getGitHubAuth() {
|
||||
const list = readAuthList();
|
||||
if (!list.length) {
|
||||
return null;
|
||||
}
|
||||
const current = list.find((entry) => entry.current) || list[0];
|
||||
if (!current?.accessToken) {
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function getGitHubAuthAccounts() {
|
||||
const list = readAuthList();
|
||||
return list
|
||||
.filter((entry) => entry?.user && entry.accountId)
|
||||
.map((entry) => ({
|
||||
id: entry.accountId,
|
||||
user: entry.user,
|
||||
scope: entry.scope || '',
|
||||
current: Boolean(entry.current),
|
||||
}));
|
||||
}
|
||||
|
||||
export function setGitHubAuth({ accessToken, scope, tokenType, user, accountId }) {
|
||||
if (!accessToken || typeof accessToken !== 'string') {
|
||||
throw new Error('accessToken is required');
|
||||
}
|
||||
writeJsonFile({
|
||||
const normalizedUser = user && typeof user === 'object'
|
||||
? {
|
||||
login: typeof user.login === 'string' ? user.login : undefined,
|
||||
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
|
||||
id: typeof user.id === 'number' ? user.id : undefined,
|
||||
name: typeof user.name === 'string' ? user.name : undefined,
|
||||
email: typeof user.email === 'string' ? user.email : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const resolvedAccountId = resolveAccountId({
|
||||
user: normalizedUser,
|
||||
accessToken,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const list = readAuthList();
|
||||
const existingIndex = list.findIndex((entry) => entry.accountId === resolvedAccountId);
|
||||
const nextEntry = {
|
||||
accessToken,
|
||||
scope: typeof scope === 'string' ? scope : '',
|
||||
tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
|
||||
createdAt: Date.now(),
|
||||
user: user && typeof user === 'object'
|
||||
? {
|
||||
login: typeof user.login === 'string' ? user.login : undefined,
|
||||
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
|
||||
id: typeof user.id === 'number' ? user.id : undefined,
|
||||
name: typeof user.name === 'string' ? user.name : undefined,
|
||||
email: typeof user.email === 'string' ? user.email : undefined,
|
||||
}
|
||||
: undefined,
|
||||
user: normalizedUser || null,
|
||||
current: true,
|
||||
accountId: resolvedAccountId,
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
list[existingIndex] = nextEntry;
|
||||
} else {
|
||||
list.push(nextEntry);
|
||||
}
|
||||
|
||||
list.forEach((entry, index) => {
|
||||
entry.current = index === (existingIndex >= 0 ? existingIndex : list.length - 1);
|
||||
});
|
||||
writeAuthList(list);
|
||||
return nextEntry;
|
||||
}
|
||||
|
||||
export function activateGitHubAuth(accountId) {
|
||||
if (typeof accountId !== 'string' || !accountId.trim()) {
|
||||
return false;
|
||||
}
|
||||
const list = readAuthList();
|
||||
const index = list.findIndex((entry) => entry.accountId === accountId.trim());
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
list.forEach((entry, idx) => {
|
||||
entry.current = idx === index;
|
||||
});
|
||||
writeAuthList(list);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clearGitHubAuth() {
|
||||
try {
|
||||
if (fs.existsSync(STORAGE_FILE)) {
|
||||
fs.unlinkSync(STORAGE_FILE);
|
||||
const list = readAuthList();
|
||||
if (!list.length) {
|
||||
return true;
|
||||
}
|
||||
const remaining = list.filter((entry) => !entry.current);
|
||||
if (!remaining.length) {
|
||||
if (fs.existsSync(STORAGE_FILE)) {
|
||||
fs.unlinkSync(STORAGE_FILE);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
remaining.forEach((entry, index) => {
|
||||
entry.current = index === 0;
|
||||
});
|
||||
writeAuthList(remaining);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to clear GitHub auth file:', error);
|
||||
|
||||
Reference in New Issue
Block a user