Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
c9f81f0
Multi user prototype for github desktop
ghedwards Dec 27, 2025
a101b06
Remove accountName and use login for multiple accounts
ghedwards Dec 28, 2025
28dd0c3
Enterprise boolean not required
ghedwards Dec 28, 2025
84b1b17
Clean up
ghedwards Dec 28, 2025
0f0eef0
Reduce changes
ghedwards Dec 28, 2025
2bfe8d1
API accounts have to be login and / or token specific
ghedwards Dec 28, 2025
453674e
Use single featureFlag and fix enableMultipleLoginAccounts() check
ghedwards Dec 29, 2025
b41a246
Merge branch 'main' into multi-user
garethedwards-tass Dec 29, 2025
5dde52f
Further progress on multiple user / login support
ghedwards Dec 29, 2025
6a4e52e
Almost there
ghedwards Dec 30, 2025
022878f
Auth appears to be mostly working for specific Clone views
ghedwards Dec 30, 2025
0632a1e
Remove rogue console.log
ghedwards Dec 30, 2025
dcee328
Fix most API calls
ghedwards Dec 31, 2025
43e27cb
Fixes for `login` disappearing and `pull all` button
ghedwards Dec 31, 2025
d9963ca
Merge branch 'multi-user' into multi-user
garethedwards-tass Dec 31, 2025
5b4b8f0
Merge pull request #4 from garethedwards-tass/multi-user
garethedwards-tass Dec 31, 2025
ddb2f02
Fix merge issue
ghedwards Dec 31, 2025
faf0445
Remove checks for empty string on login property, add temporary debug…
ghedwards Jan 1, 2026
09c306e
Debugging for specific cases
ghedwards Jan 1, 2026
7969e0e
Merge pull request #5 from desktop-plus/multi-user
ghedwards Jan 2, 2026
e4d42e5
Merge branch 'pol-rivero:main' into multi-user
ghedwards Jan 2, 2026
e2721db
Replace github icons with flatpak icons
ghedwards Jan 2, 2026
d6cbf84
Merge pull request #6 from desktop-plus/non-github-icons
garethedwards-tass Jan 2, 2026
0119075
Set user.name and user.email where login is passed in and an account …
ghedwards Jan 2, 2026
453c5ff
Fix default account(s) for other git providers
ghedwards Jan 3, 2026
a4f29ce
Create row key to prevent console warnings and for react optimisation
ghedwards Jan 3, 2026
090b39a
Merge branch 'main' into multi-user
ghedwards Jan 3, 2026
067b71a
Update Repository to show URL, endpoint and account with ability to c…
ghedwards Jan 3, 2026
62ef631
Revert "Replace github icons with flatpak icons"
ghedwards Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 96 additions & 18 deletions app/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { HttpStatusCode } from './http-status-code'
import { CopilotError } from './copilot-error'
import { BypassReasonType } from '../ui/secret-scanning/bypass-push-protection-dialog'
import { enableMultipleLoginAccounts } from './feature-flag'
import { assertNever } from './fatal-error'

const envEndpoint = process.env['DESKTOP_GITHUB_DOTCOM_API_ENDPOINT']
Expand Down Expand Up @@ -190,6 +191,7 @@ export interface IAPIRepository {
readonly ssh_url: string
readonly html_url: string
readonly name: string
readonly login?: string
readonly owner: IAPIIdentity
readonly private: boolean | null // null if unknown
readonly fork: boolean
Expand Down Expand Up @@ -237,6 +239,7 @@ export interface IAPIRepositoryCloneInfo {
/** Canonical clone URL of the repository. */
readonly url: string

readonly login?: string
/**
* Default branch of the repository, if any. This is usually either retrieved
* from the API for GitHub repositories, or undefined for other repositories.
Expand Down Expand Up @@ -284,6 +287,7 @@ export interface IBitbucketAPIRepository
readonly parent?: IBitbucketAPIRepository
readonly has_issues: boolean
readonly updated_on: string
readonly login?: string
readonly mainbranch: {
readonly name: string
}
Expand All @@ -308,6 +312,7 @@ function toIAPIRepository(repo: IBitbucketAPIRepository): IAPIRepository {
ssh_url: sshUrl,
html_url: repo.links.html.href,
name: repo.name,
login: repo.login,
owner: toIAPIIdentity(repo.owner),
private: repo.is_private,
fork: false,
Expand Down Expand Up @@ -1077,6 +1082,7 @@ function toIAPIEmailFromGitLab(
export interface IGitLabAPIRepository {
readonly id: number
readonly name: string
readonly login: string
readonly path: string
readonly path_with_namespace: string
readonly web_url: string
Expand Down Expand Up @@ -1104,6 +1110,7 @@ function toIAPIRepositoryFromGitLab(
ssh_url: repo.ssh_url_to_repo,
html_url: repo.web_url,
name: repo.path,
login: repo.login,
owner: {
id: repo.owner?.id ?? 0,
login: ownerLogin,
Expand Down Expand Up @@ -1392,12 +1399,17 @@ interface IAPIAliveWebSocket {
readonly url: string
}

type TokenInvalidatedCallback = (endpoint: string, token: string) => void
type TokenInvalidatedCallback = (
endpoint: string,
token: string,
login?: string
) => void
type TokenRefreshedCallback = (
endpoint: string,
token: string,
refreshToken: string,
expiresAt: number
expiresAt: number,
login?: string
) => void

export interface IAPICreatePushProtectionBypassResponse {
Expand Down Expand Up @@ -1428,20 +1440,25 @@ export class API {
this.tokenRefreshedListeners.add(callback)
}

protected static emitTokenInvalidated(endpoint: string, token: string) {
protected static emitTokenInvalidated(
endpoint: string,
token: string,
login?: string
) {
this.tokenInvalidatedListeners.forEach(callback =>
callback(endpoint, token)
callback(endpoint, token, login)
)
}

protected static emitTokenRefreshed(
endpoint: string,
token: string,
refreshToken: string,
expiresAt: number
expiresAt: number,
login?: string
) {
this.tokenRefreshedListeners.forEach(callback =>
callback(endpoint, token, refreshToken, expiresAt)
callback(endpoint, token, refreshToken, expiresAt, login)
)
}

Expand All @@ -1464,26 +1481,34 @@ export class API {
)
case 'dotcom':
case 'enterprise':
return new API(account.endpoint, account.token, account.copilotEndpoint)
return new API(
account.endpoint,
account.token,
account.copilotEndpoint,
account.login
)
default:
assertNever(account.apiType, 'Unknown API type')
}
}

protected endpoint: string
protected token: string
protected login?: string
private copilotEndpoint?: string
private refreshTokenPromise?: Promise<void>

/** Create a new API client for the endpoint, authenticated with the token. */
public constructor(
endpoint: string,
token: string,
copilotEndpoint?: string
copilotEndpoint?: string,
login?: string
) {
this.endpoint = endpoint
this.token = token
this.copilotEndpoint = copilotEndpoint
this.login = login
}

public getToken() {
Expand Down Expand Up @@ -1648,7 +1673,8 @@ export class API {
public async fetchRepositoryCloneInfo(
owner: string,
name: string,
protocol: GitProtocol | undefined
protocol: GitProtocol | undefined,
login?: string
): Promise<IAPIRepositoryCloneInfo | null> {
const response = await this.ghRequest('GET', `repos/${owner}/${name}`, {
// Make sure we don't run into cache issues when fetching the repositories,
Expand All @@ -1663,6 +1689,7 @@ export class API {
const repo = await parsedResponse<IAPIRepository>(response)
return {
url: protocol === 'ssh' ? repo.ssh_url : repo.clone_url,
login,
defaultBranch: repo.default_branch,
}
}
Expand Down Expand Up @@ -2474,7 +2501,8 @@ export class API {
body?: Object
customHeaders?: Object
reloadCache?: boolean
} = {}
} = {},
login?: string
): Promise<Response> {
const expiration = this.getTokenExpiration()
if (expiration !== null && expiration.getTime() < Date.now()) {
Expand All @@ -2489,7 +2517,8 @@ export class API {
path,
options.body,
{ ...this.getExtraHeaders(), ...options.customHeaders },
options.reloadCache
options.reloadCache,
login
)
}

Expand All @@ -2506,7 +2535,13 @@ export class API {
reloadCache?: boolean
} = {}
): Promise<Response> {
const response = await this.request(this.endpoint, method, path, options)
const response = await this.request(
this.endpoint,
method,
path,
options,
this.login
)

this.checkTokenInvalidated(response)

Expand Down Expand Up @@ -2926,7 +2961,8 @@ export class BitbucketAPI extends API {
this.endpoint,
this.token,
this.apiRefreshToken,
this.expiresAt.getTime()
this.expiresAt.getTime(),
this.login
)
} catch (e) {
log.warn('refreshOAuthTokenBitbucket failed', e)
Expand Down Expand Up @@ -3282,7 +3318,8 @@ export class GitLabAPI extends API {
this.endpoint,
this.token,
this.apiRefreshToken,
this.expiresAt.getTime()
this.expiresAt.getTime(),
this.login
)
} catch (e) {
log.warn('refreshOAuthTokenGitLab failed', e)
Expand Down Expand Up @@ -3603,15 +3640,16 @@ export async function fetchUser(
endpoint: string,
token: string,
refreshToken: string,
expiresAt: number
expiresAt: number,
login?: string
): Promise<Account> {
let api: API
if (endpoint === getBitbucketAPIEndpoint()) {
api = new BitbucketAPI(token, refreshToken, expiresAt)
} else if (endpoint === getGitLabAPIEndpoint()) {
api = GitLabAPI.get(token, refreshToken, expiresAt)
} else {
api = new API(endpoint, token)
api = new API(endpoint, token, undefined, login)
}
try {
const [user, emails, copilotInfo, features] = await Promise.all([
Expand Down Expand Up @@ -3757,9 +3795,49 @@ export function getGitLabAPIEndpoint(): string {
/** Get the account for the endpoint. */
export function getAccountForEndpoint(
accounts: ReadonlyArray<Account>,
endpoint: string
endpoint: string,
login?: string,
strict: boolean = false
): Account | null {
if (login !== undefined && login === '') {
// TODO: This is here temporarily for debugging, remove it when we're sure this isn't a possibility
log.error(`Empty string is not a valid login`)
}

const result = accounts.find(
a =>
a.endpoint === endpoint &&
((strict !== true && login === undefined) || a.login === login)
)

if (login !== undefined && result === undefined) {
// TODO: This is here temporarily for debugging, remove it when we're sure this isn't a possibility
log.warn(`Could not find an account to match ${login}@${endpoint}`)
}

return result || null
}

export function getAccountForEndpointToken(
accounts: ReadonlyArray<Account>,
endpoint: string,
token: string
): Account | null {
return (
accounts.find(
a =>
a.endpoint === endpoint &&
(!enableMultipleLoginAccounts() || a.token === token)
) || null
)
}

/** Get the account for the login. */
export function getAccountForLogin(
accounts: ReadonlyArray<Account>,
login: string
): Account | null {
return accounts.find(a => a.endpoint === endpoint) || null
return accounts.find(a => a.login === login) || null
}

export function getOAuthAuthorizationURL(
Expand Down
17 changes: 16 additions & 1 deletion app/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { Account } from '../models/account'
import { enableMultipleLoginAccounts } from './feature-flag'

/** Get the auth key for the user. */
export function getKeyForAccount(account: Account): string {
return getKeyForEndpoint(account.endpoint)
if (enableMultipleLoginAccounts()) {
return getKeyForEndpointAndLogin(account.endpoint, account.login)
} else {
return getKeyForEndpoint(account.endpoint)
}
}

/** Get the auth key for the endpoint. */
Expand All @@ -11,3 +16,13 @@ export function getKeyForEndpoint(endpoint: string): string {

return `${appName} - ${endpoint}`
}

/** Get the auth key for the endpoint. */
export function getKeyForEndpointAndLogin(
endpoint: string,
login: string | undefined
): string {
const appName = __DEV__ ? 'GitHub Desktop Dev' : 'GitHub'

return `${appName} - ${endpoint} - ${login}`
}
2 changes: 2 additions & 0 deletions app/src/lib/databases/repositories-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export interface IDatabaseGitHubRepository {
readonly isArchived?: boolean

readonly permissions?: 'read' | 'write' | 'admin' | null
readonly login?: string
}

/** A record to track the protected branch information for a GitHub repository */
Expand Down Expand Up @@ -69,6 +70,7 @@ export interface IDatabaseRepository {
* of Git and GitHub.
*/
readonly isTutorialRepository?: boolean
readonly login?: string
}

/**
Expand Down
3 changes: 2 additions & 1 deletion app/src/lib/desktop-fake-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export const DesktopFakeRepository = new Repository(
false,
desktopUrl
),
true
true,
''
)
1 change: 1 addition & 0 deletions app/src/lib/feature-flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export const enableResizingToolbarButtons = () => true

export const enableFilteredChangesList = () => true
export const enableMultipleEnterpriseAccounts = () => true
export const enableMultipleLoginAccounts = () => true

export const enableCommitMessageGeneration = (account: Account) => {
return (
Expand Down
Loading