diff --git a/src/mobile-web/src/pages/PairingPage.tsx b/src/mobile-web/src/pages/PairingPage.tsx
index ccfca4ba12..fbf6460afc 100644
--- a/src/mobile-web/src/pages/PairingPage.tsx
+++ b/src/mobile-web/src/pages/PairingPage.tsx
@@ -3,9 +3,6 @@ import { MobileStatus } from '@openbitfun/ui/mobile';
import PairingForm from '../components/PairingForm';
import { accountDeviceIdFromHash, currentRelayUrl } from '../services/pairingLink';
import { useI18n } from '../i18n';
-import { useTheme } from '../theme';
-import logoMarkDark from '../assets/openbitfun-mark-dark.png';
-import logoMarkLight from '../assets/openbitfun-mark-light.png';
import { CloudAccountClient, type CloudAccountSession } from '../services/CloudAccountClient';
import {
BrowserAccountChangedError, BrowserAccountStorageError, getBrowserAccountStore, releaseBrowserAccount,
@@ -26,7 +23,6 @@ function routeKey(): string { return `${window.location.pathname}${window.locati
const PairingPageContent: React.FC
= ({ onPaired }) => {
const { t } = useI18n();
- const { isDark } = useTheme();
const relayUrl = currentRelayUrl();
const accountStore = getBrowserAccountStore(relayUrl);
const [restoring, setRestoring] = useState(true);
@@ -113,9 +109,16 @@ const PairingPageContent: React.FC = ({ onPaired }) => {
const signIn = async () => {
if (pending.current || connected.current || restoring) return;
- const authWindow = window.open('about:blank', '_blank');
+ // Open synchronously in the user gesture so the browser permits the popup.
+ const width = Math.min(480, window.screen.availWidth);
+ const height = Math.min(720, window.screen.availHeight);
+ const left = Math.max(0, window.screenX + (window.outerWidth - width) / 2);
+ const top = Math.max(0, window.screenY + (window.outerHeight - height) / 2);
+ const authWindow = window.open('about:blank', '_blank',
+ `popup=yes,width=${width},height=${height},left=${Math.round(left)},top=${Math.round(top)},resizable=yes,scrollbars=yes`);
if (!authWindow) { setError(t('pairing.allowSignInPopup')); return; }
- authWindow.opener = null;
+ // Keep the opener relationship: Chrome otherwise refuses cross-origin close()
+ // and focus(). CloudAccountClient only navigates to the trusted auth endpoints.
popup.current = authWindow;
const attempt = ++generation.current;
const controller = new AbortController();
@@ -168,12 +171,15 @@ const PairingPageContent: React.FC = ({ onPaired }) => {
return
-

+
OpenBitFun
{restoring ?
- : void signIn()} onCancel={cancel} />}
+ : void signIn()} onCancel={cancel} onFocus={() => {
+ if (popup.current && !popup.current.closed) popup.current.focus();
+ else { cancel(); void signIn(); }
+ }} />}
;
};
diff --git a/src/mobile-web/src/services/CloudAccountClient.ts b/src/mobile-web/src/services/CloudAccountClient.ts
index 4d0e3abe09..2eb87e1697 100644
--- a/src/mobile-web/src/services/CloudAccountClient.ts
+++ b/src/mobile-web/src/services/CloudAccountClient.ts
@@ -86,11 +86,12 @@ export class CloudAccountClient {
this.relayUrl = endpoint;
}
async authorize(popup: Window, signal: AbortSignal): Promise {
- const start = await requestJson(this.relayUrl, '/api/auth/github/start', {});
+ const start = await requestJson(this.relayUrl, '/api/auth/github/start?methods=all', {});
const url = new URL(start.authorizationUrl);
- if (url.origin !== 'https://github.com' || url.pathname !== '/login/oauth/authorize' || url.username || url.password) {
+ if (!((url.origin === 'https://github.com' && url.pathname === '/login/oauth/authorize') || (url.origin === 'https://auth.openbitfun.com' && url.pathname === '/sign-in')) || url.username || url.password) {
throw new Error('Untrusted account authorization URL.');
}
+ if (url.origin === 'https://auth.openbitfun.com') url.searchParams.set('locale', (typeof document === 'undefined' ? 'en-US' : document.documentElement.lang || 'en-US'));
if (signal.aborted) throw new Error('Sign-in cancelled.');
popup.location.href = url.href;
while (!signal.aborted && Date.now() < start.expiresAt * 1000) {
@@ -104,7 +105,7 @@ export class CloudAccountClient {
if (result.status === 'authorized' && result.tokens?.accessToken) return result.tokens.accessToken;
if (result.status === 'expired' || result.status === 'denied') break;
}
- throw new Error(signal.aborted ? 'Sign-in cancelled.' : 'GitHub sign-in expired. Try again.');
+ throw new Error(signal.aborted ? 'Sign-in cancelled.' : 'Sign-in expired. Try again.');
}
async login(accessToken: string, deviceId: string, browserPrivateKey: Uint8Array): Promise {
diff --git a/src/mobile-web/src/services/RelayHttpClient.ts b/src/mobile-web/src/services/RelayHttpClient.ts
index 8261c1ac44..802d199130 100644
--- a/src/mobile-web/src/services/RelayHttpClient.ts
+++ b/src/mobile-web/src/services/RelayHttpClient.ts
@@ -302,7 +302,7 @@ export class RelayHttpClient {
private async withAccount(operation: (identity: AccountIdentitySnapshot) => Promise): Promise {
const identity = this.identity;
- if (!identity) throw new Error('Sign in with GitHub to continue');
+ if (!identity) throw new Error('Sign in to continue');
try {
const result = await operation(identity);
if (this.identity !== identity) throw new AccountIdentityChangedError();
diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts
index 71826c4456..616b10c472 100644
--- a/src/mobile-web/src/services/RemoteSessionManager.ts
+++ b/src/mobile-web/src/services/RemoteSessionManager.ts
@@ -608,7 +608,7 @@ export class RemoteSessionManager {
async ping(): Promise {
const controllerDeviceId = this.client.controllerDeviceId;
- if (!controllerDeviceId) throw new Error('Sign in with GitHub to continue');
+ if (!controllerDeviceId) throw new Error('Sign in to continue');
await this.request({ cmd: 'ping', client: getControlClientIdentity(controllerDeviceId) });
}
diff --git a/src/mobile-web/src/styles/components/devices.scss b/src/mobile-web/src/styles/components/devices.scss
index 40762d27c2..df86642a92 100644
--- a/src/mobile-web/src/styles/components/devices.scss
+++ b/src/mobile-web/src/styles/components/devices.scss
@@ -222,3 +222,38 @@
.devices-page__body { width: min(100%, 720px); margin-inline: auto; }
.devices-page__error { width: 680px; margin-inline: auto; }
}
+
+.devices-page__onboarding {
+ width: min(100%, 480px);
+ margin: var(--size-gap-6) auto;
+ padding: var(--size-gap-6);
+ border: 1px solid var(--openbitfun-color-border-subtle);
+ border-radius: var(--size-radius-xl);
+ background: var(--openbitfun-color-surface-panel);
+ h2 { margin: var(--size-gap-5) 0 var(--size-gap-2); font-size: var(--openbitfun-type-heading-dialog-font-size); line-height: var(--openbitfun-type-heading-dialog-line-height); color: var(--openbitfun-color-content-primary); }
+}
+.devices-page__onboarding-icon {
+ display: grid; place-items: center; width: 56px; height: 56px;
+ border: 1px solid var(--openbitfun-color-border-subtle);
+ border-radius: var(--size-radius-lg);
+ background: var(--openbitfun-color-surface-tertiary);
+ color: var(--openbitfun-color-content-secondary);
+}
+.devices-page__onboarding-intro, .devices-page__steps p {
+ margin: 0; font-size: var(--openbitfun-type-body-md-font-size);
+ line-height: var(--openbitfun-type-body-md-line-height); color: var(--openbitfun-color-content-secondary);
+}
+.devices-page__steps {
+ list-style: none; display: grid; gap: var(--size-gap-5); margin: var(--size-gap-6) 0; padding: 0;
+ li { display: flex; align-items: flex-start; gap: var(--size-gap-3); }
+ li > span { display: grid; place-items: center; flex-shrink: 0; width: 24px; height: 24px; border-radius: 50%; background: var(--openbitfun-color-surface-tertiary); color: var(--openbitfun-color-content-secondary); font-size: var(--openbitfun-type-body-xs-font-size); }
+ strong { display: block; margin-bottom: var(--size-gap-1); font-size: var(--openbitfun-type-body-md-font-size); color: var(--openbitfun-color-content-primary); }
+}
+.devices-page__onboarding-note { margin: var(--size-gap-3) 0 0; text-align: center; color: var(--openbitfun-color-content-muted); font-size: var(--openbitfun-type-body-xs-font-size); line-height: var(--openbitfun-type-body-md-line-height); }
+@media (max-width: 380px) { .devices-page__onboarding { padding: var(--size-gap-5); margin-block: var(--size-gap-3); } }
+
+.devices-page__header--account {
+ grid-template-columns: minmax(0, 1fr) auto;
+ [data-openbitfun-part='leading'] { display: none; }
+}
+.devices-page--empty .devices-page__header { width: min(100%, 520px); margin-inline: auto; }
diff --git a/src/mobile-web/src/styles/components/pairing.scss b/src/mobile-web/src/styles/components/pairing.scss
index 2ab23df3b5..cbab96d61c 100644
--- a/src/mobile-web/src/styles/components/pairing.scss
+++ b/src/mobile-web/src/styles/components/pairing.scss
@@ -80,3 +80,29 @@
.pairing-page { padding-inline: var(--size-gap-4); }
.pairing-page__panel { padding: var(--size-gap-5); }
}
+
+.pairing-page__symbol {
+ display: grid;
+ place-items: center;
+ width: 48px;
+ height: 48px;
+ margin-bottom: var(--size-gap-2);
+ border: 1px solid var(--openbitfun-color-border-subtle);
+ border-radius: var(--size-radius-lg);
+ color: var(--openbitfun-color-content-secondary);
+ background: var(--openbitfun-color-surface-tertiary);
+}
+.pairing-page__waiting {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--size-gap-3);
+ padding: var(--size-gap-4);
+ margin-top: var(--size-gap-2);
+ border-radius: var(--size-radius-lg);
+ background: var(--openbitfun-color-surface-tertiary);
+ color: var(--openbitfun-color-content-secondary);
+ font-size: var(--openbitfun-type-body-md-font-size);
+ line-height: var(--openbitfun-type-body-md-line-height);
+ svg { flex-shrink: 0; }
+ p { margin: 0; }
+}
diff --git a/src/mobile-web/tests/account-login.test.mjs b/src/mobile-web/tests/account-login.test.mjs
index af2940bf61..cac08a9044 100644
--- a/src/mobile-web/tests/account-login.test.mjs
+++ b/src/mobile-web/tests/account-login.test.mjs
@@ -148,13 +148,17 @@ test('authorization follows the central GitHub OAuth URL and rejects lookalike d
try {
for (const authorizationUrl of [
'https://github.com/login/oauth/authorize?state=test',
+ 'https://auth.openbitfun.com/sign-in#ticket=test',
+ 'https://auth.openbitfun.com.evil.example/sign-in',
+ 'https://user@auth.openbitfun.com/sign-in',
+ 'https://auth.openbitfun.com/other',
'https://github.com.attacker.example/login/oauth/authorize',
'https://github.com/login', 'https://user@github.com/login/oauth/authorize',
'http://github.com/login/oauth/authorize',
]) {
let polls = 0;
globalThis.fetch = async url => {
- if (url.endsWith('/start')) return Response.json({
+ if (url.endsWith('/start?methods=all')) return Response.json({
transactionId: 'txn', transactionSecret: 'secret', authorizationUrl,
expiresAt: Date.now() / 1000 + 60, pollIntervalSeconds: 3,
});
@@ -163,9 +167,9 @@ test('authorization follows the central GitHub OAuth URL and rejects lookalike d
};
const popup = { location: { href: 'about:blank' } };
const result = new CloudAccountClient('https://remote.openbitfun.com/v/1.0.1').authorize(popup, new AbortController().signal);
- if (authorizationUrl === 'https://github.com/login/oauth/authorize?state=test') {
+ if (authorizationUrl === 'https://github.com/login/oauth/authorize?state=test' || authorizationUrl === 'https://auth.openbitfun.com/sign-in#ticket=test') {
assert.equal(await result, 'verified');
- assert.equal(popup.location.href, authorizationUrl);
+ assert.equal(popup.location.href, authorizationUrl.startsWith('https://auth.openbitfun.com') ? authorizationUrl.replace('/sign-in#', '/sign-in?locale=en-US#') : authorizationUrl);
assert.equal(polls, 1);
} else {
await assert.rejects(result, /Untrusted/);
diff --git a/src/mobile-web/tests/browser-account.test.mjs b/src/mobile-web/tests/browser-account.test.mjs
index 99557626f4..c72e3ebff5 100644
--- a/src/mobile-web/tests/browser-account.test.mjs
+++ b/src/mobile-web/tests/browser-account.test.mjs
@@ -407,3 +407,55 @@ test('a retired official relay token stays archived and is never rebound to the
assert.equal(result.session,null);assert.deepEqual(result.retained,result.old);
}finally{await context.close();}
});
+
+test('sign-in uses a separate popup, can cancel and reopen a closed window, and closes on success', { timeout: 40_000 }, async () => {
+ const context = await browser.createIncognitoBrowserContext();
+ const relay = new RelayFixture();
+ try {
+ const page = await relay.page(context, source.origin, invitation());
+ await page.waitForSelector('.pairing-page__form button[type="submit"]');
+ await page.evaluate(async () => {
+ const { CloudAccountClient } = await import('/src/services/CloudAccountClient.ts');
+ CloudAccountClient.prototype.authorize = async (popup, signal) => {
+ window.authFixturePopup = popup;
+ return new Promise((resolve, reject) => {
+ window.completeAuthFixture = () => resolve('fixture-user-123');
+ signal.addEventListener('abort', () => reject(new Error('Cancelled')), { once: true });
+ });
+ };
+ });
+ const open = async (selector) => {
+ await page.evaluate(() => { window.completeAuthFixture = undefined; });
+ const targetPromise = browser.waitForTarget(target => target.opener() === page.target() && target.type() === 'page');
+ await page.click(selector);
+ const target = await targetPromise;
+ const popupPage = await target.page();
+ await page.waitForFunction(() => typeof window.completeAuthFixture === 'function');
+ await popupPage.setRequestInterception(true);
+ popupPage.on('request', request => request.respond({ status: 200, contentType: 'text/html', body: 'Authentication fixture
' }));
+ await popupPage.goto('https://auth.openbitfun.com/sign-in');
+ assert.equal(await popupPage.evaluate(() => window.opener !== null), true);
+ const parentSession = await page.target().createCDPSession();
+ const popupSession = await target.createCDPSession();
+ const parentWindow = await parentSession.send('Browser.getWindowForTarget');
+ const authWindow = await popupSession.send('Browser.getWindowForTarget');
+ assert.notEqual(authWindow.windowId, parentWindow.windowId);
+ assert.ok(authWindow.bounds.width <= 500);
+ await parentSession.detach(); await popupSession.detach();
+ return popupPage;
+ };
+ const first = await open('.pairing-page__retry');
+ await page.click('.pairing-page__action button:last-child');
+ await until(() => first.isClosed());
+ assert.equal(relay.logins.length, 0);
+ const second = await open('.pairing-page__retry');
+ await second.close();
+ // The return action starts a fresh attempt if the user closed the popup.
+ const third = await open('.pairing-page__retry');
+ await page.evaluate(() => window.completeAuthFixture());
+ await connected(page);
+ await until(() => third.isClosed());
+ assert.equal(relay.logins.length, 1);
+ assert.deepEqual(relay.errors, []);
+ } finally { await context.close(); }
+});
diff --git a/src/skin-market-web/README.md b/src/skin-market-web/README.md
index fcba761d93..936e80a781 100644
--- a/src/skin-market-web/README.md
+++ b/src/skin-market-web/README.md
@@ -13,7 +13,7 @@ Appearance packages.
`src/links.ts`).
The site is self-contained and does not import the main Web UI locale or theme catalogs.
-GitHub identity is shared with the MiniApp market through its same-origin auth
+GitHub and independent email-code identity are shared with the MiniApp market through its same-origin auth
broker. Skin writes use the `/skin`-scoped CSRF alias issued by that broker.
Local development proxies `/miniapp/api` to `127.0.0.1:9710`; set
`MINIAPP_MARKET_DEV_API` when the broker runs elsewhere.
diff --git a/src/skin-market-web/src/AdminPage.tsx b/src/skin-market-web/src/AdminPage.tsx
index 70ff2befbb..e3c5d935e0 100644
--- a/src/skin-market-web/src/AdminPage.tsx
+++ b/src/skin-market-web/src/AdminPage.tsx
@@ -178,7 +178,7 @@ export function AdminPage({ account, accountResolved, locale, t }: AdminPageProp
{t('reviewSubmitter')}
- {detail.submitter ? (
+ {detail.submitter ? detail.submitter.githubId > 0 ? (
@{detail.submitter.login}
- ) : t('reviewSubmitterUnknown')}
+ ) : {detail.submitter.login} : t('reviewSubmitterUnknown')}
{t('packageIdentity')}{detail.submission.packageId || t('notDeclared')}
diff --git a/src/skin-market-web/src/App.tsx b/src/skin-market-web/src/App.tsx
index 24c4eb354f..445b691a4e 100644
--- a/src/skin-market-web/src/App.tsx
+++ b/src/skin-market-web/src/App.tsx
@@ -1,6 +1,6 @@
import { Button, IconButton } from '@openbitfun/ui';
-import { GithubLogo } from '@phosphor-icons/react';
import {
+ LogIn,
RefreshCw as ArrowClockwise,
ExternalLink as ArrowSquareOut,
Globe as GlobeSimple,
@@ -36,6 +36,8 @@ export default function App() {
currentRoute().kind === 'catalog' ? window.location.search : '',
);
const [account, setAccount] = useState();
+ const [failedAvatar, setFailedAvatar] = useState(null);
+ const accountLabel = account?.email ?? account?.user.login ?? '';
const [accountResolved, setAccountResolved] = useState(false);
const [accountBusy, setAccountBusy] = useState(false);
const [accountError, setAccountError] = useState();
@@ -59,7 +61,7 @@ export default function App() {
useEffect(() => {
void sharedMarketAccountApi
.config()
- .then((config) => setGithubAuthConfigured(config.githubAuthConfigured))
+ .then((config) => setGithubAuthConfigured(config.githubAuthConfigured || config.emailAuthConfigured === true))
.catch(() => undefined);
void refreshAccount();
}, [refreshAccount]);
@@ -168,8 +170,12 @@ export default function App() {
) : account ? (