diff --git a/backend/routes/auth.js b/backend/routes/auth.js index b3dec1b..a805b33 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -126,6 +126,32 @@ function makeAuthRouter({ authService, storageService }) { } }); + router.get('/storage/discover', requireAuth, async (req, res) => { + try { + const provider = req.query.provider || 'github'; + if (provider !== 'github') { + return res.status(400).json({ + error: 'Only provider=github is supported in Phase 1', + }); + } + + const clients = await authService.clientsFor(req.user); + if (!clients.githubUserClient && !clients.githubClient) { + return res.status(400).json({ error: 'GitHub client is not available' }); + } + + const result = await storageService.discoverAccountStores(provider, clients, { + sessionStorageRef: req.user?.storage || null, + }); + return res.json(result); + } catch (err) { + console.error(err); + return res.status(500).json({ + error: err.message || 'Failed to discover storage', + }); + } + }); + router.post('/storage/create', requireAuth, async (req, res) => { try { const { name, provider = 'github' } = req.body || {}; @@ -134,9 +160,6 @@ function makeAuthRouter({ authService, storageService }) { error: 'Only provider=github is supported for repository creation', }); } - if (!name || typeof name !== 'string') { - return res.status(400).json({ error: 'name is required' }); - } const clients = await authService.clientsFor(req.user); if (!clients.githubUserClient && !clients.githubClient) { @@ -144,9 +167,10 @@ function makeAuthRouter({ authService, storageService }) { } const installUrl = await authService.getInstallationSetupUrl(); - const result = await storageService.createGitHubRepository(name, clients, { - installUrl, - }); + const result = + typeof name === 'string' && name.trim() + ? await storageService.createGitHubRepository(name.trim(), clients, { installUrl }) + : await storageService.createNextVizablyGitHubRepository(clients, { installUrl }); return res.status(201).json({ provider: 'github', storageRef: result.storageRef, diff --git a/backend/services/storageService.js b/backend/services/storageService.js index ee46cc0..833abf6 100644 --- a/backend/services/storageService.js +++ b/backend/services/storageService.js @@ -7,7 +7,12 @@ */ const crypto = require('crypto'); const { randomUUID } = require('crypto'); -const { normalizeGitHubRepoName } = require('../../shared/githubRepoName'); +const { + applyVizablyRepoPrefix, + nextVizablyStoreName, + normalizeGitHubRepoName, + VIZABLY_DEFAULT_STORE_NAME, +} = require('../../shared/githubRepoName'); const { collectAllGitHubPages, findInGitHubPages } = require('./githubPagination'); const MANIFEST_PATH = 'vizably.json'; @@ -160,7 +165,7 @@ class StorageService { * Create a private empty GitHub repo for the signed-in user (App UAT). * Does not initialize a Vizably store — caller runs fit-check then init. * - * @param {string} name repository name (not owner/name) + * @param {string} name repository name (not owner/name); stored as `viz_` * @param {StorageClients} clients must include githubUserClient (or githubClient as UAT) * @param {object} [options] * @param {string} [options.installUrl] App install URL when needsInstall @@ -215,6 +220,162 @@ class StorageService { return { storageRef, needsInstall, installUrl }; } + /** + * Create the next unused default store: `viz_scans`, then `viz_scans-2`, … + * + * @param {StorageClients} clients + * @param {object} [options] + * @param {string} [options.installUrl] + */ + async createNextVizablyGitHubRepository(clients, options = {}) { + const taken = []; + for (let n = 1; n <= 50; n += 1) { + const name = nextVizablyStoreName(taken); + try { + return await this.createGitHubRepository(name, clients, options); + } catch (err) { + if (err.code === 'REPO_NAME_TAKEN') { + taken.push(name); + continue; + } + throw err; + } + } + const err = new Error('Could not find an available Vizably repository name'); + err.status = 422; + err.code = 'REPO_NAME_TAKEN'; + throw err; + } + + /** + * Find existing Vizably account stores (manifest-based, provider-neutral). + * Order: session storageRef → GET expected name (`viz_scans`) → list repos. + * + * @param {'github' | 'google'} provider + * @param {StorageClients} clients + * @param {object} [options] + * @param {object} [options.sessionStorageRef] + * @returns {Promise<{ + * provider: string, + * stores: Array<{ storageRef: object, validation: object }>, + * source: 'session' | 'expected-name' | 'list' | null, + * }>} + */ + async discoverAccountStores(provider, clients, options = {}) { + if (provider === 'google') { + return { provider, stores: [], source: null }; + } + if (provider !== 'github') { + throw new Error('Unsupported storage provider'); + } + return this._discoverGitHubAccountStores(clients, options); + } + + /** + * @param {StorageClients} clients + * @param {{ sessionStorageRef?: object }} [options] + * @private + */ + async _discoverGitHubAccountStores(clients, { sessionStorageRef } = {}) { + const octokit = clients.githubUserClient ?? clients.githubClient; + if (!octokit) { + throw new Error('GitHub client is required to discover storage'); + } + + const consider = async (storageRef) => { + const validation = await this.validateStorage('github', storageRef, clients); + if (!this._isDiscoveredAccountStore(validation)) { + return null; + } + return { + storageRef: { + id: storageRef.id, + full_name: storageRef.full_name, + html_url: storageRef.html_url, + name: storageRef.name || storageRef.full_name?.split('/')[1], + }, + validation, + }; + }; + + if (sessionStorageRef?.full_name || sessionStorageRef?.id) { + try { + const hit = await consider(sessionStorageRef); + if (hit) { + return { provider: 'github', stores: [hit], source: 'session' }; + } + } catch { + // Stale session ref — fall through to name GET / listing. + } + } + + let owner; + try { + const { data: user } = await octokit.rest.users.getAuthenticated(); + owner = user.login; + } catch (err) { + throw new Error( + err?.status === 401 + ? 'GitHub authentication failed. Sign out and sign in again.' + : 'Could not look up your GitHub username.', + ); + } + + try { + const { data: repo } = await octokit.rest.repos.get({ + owner, + repo: VIZABLY_DEFAULT_STORE_NAME, + }); + const hit = await consider({ + id: repo.node_id, + full_name: repo.full_name, + html_url: repo.html_url, + name: repo.name, + }); + if (hit) { + return { provider: 'github', stores: [hit], source: 'expected-name' }; + } + } catch (err) { + if (err?.status === 429 || (err?.status === 403 && /rate limit/i.test(String(err.message)))) { + throw err; + } + } + + const repos = await this.listGitHubRepos(octokit); + const stores = []; + for (const repo of repos) { + try { + const hit = await consider({ + id: repo.id, + full_name: repo.full_name, + html_url: repo.html_url, + }); + if (hit) { + stores.push(hit); + } + } catch { + // Skip repos we cannot inspect. + } + } + return { provider: 'github', stores, source: 'list' }; + } + + /** + * A discovered account store is identified by vizably.json (or legacy + * equalview.json), never by repository name. + * @param {{ status?: string, reason?: string | null }} validation + * @private + */ + _isDiscoveredAccountStore(validation) { + if (!validation) { + return false; + } + if (validation.status === 'loadable' || validation.status === 'incompatible') { + return true; + } + return validation.status === 'invalid' && validation.reason === 'malformed_manifest'; + } + /** * True when a Vizably App installation with Contents write includes this repo. * Does not fall back to the user's personal push bit — create needs the App. @@ -390,7 +551,7 @@ class StorageService { throw err; } - const normalized = normalizeGitHubRepoName(name); + const normalized = applyVizablyRepoPrefix(name); if (!normalized) { const err = new Error('Repository name is required'); err.status = 400; diff --git a/backend/tests/auth.test.js b/backend/tests/auth.test.js index 36b0998..c9720ca 100644 --- a/backend/tests/auth.test.js +++ b/backend/tests/auth.test.js @@ -388,18 +388,64 @@ test('POST /api/auth/storage/create returns storageRef and needsInstall', async assert.match(res.body.installUrl, /installations\/new/); }); -test('POST /api/auth/storage/create requires name', async () => { +test('GET /api/auth/storage/discover returns discovered stores', async () => { const app = createAuthedApp({ user: AUTHED_USER, authService: { - clientsFor: async () => ({ githubUserClient: {} }), - getInstallationSetupUrl: async () => 'https://github.com/settings/installations', + clientsFor: async () => ({ githubUserClient: { mock: true } }), + }, + storageService: { + discoverAccountStores: async (provider, _clients, options) => { + assert.equal(provider, 'github'); + assert.equal(options.sessionStorageRef, AUTHED_USER.storage); + return { + provider: 'github', + stores: [ + { + storageRef: { + id: 'R_kg', + full_name: 'sam/viz_scans', + html_url: 'https://github.com/sam/viz_scans', + }, + validation: { status: 'loadable' }, + }, + ], + source: 'expected-name', + }; + }, }, - storageService: {}, }); - const res = await request(app).post('/api/auth/storage/create').send({}); - assert.equal(res.status, 400); - assert.match(res.body.error, /name is required/); + const res = await request(app).get('/api/auth/storage/discover?provider=github'); + assert.equal(res.status, 200); + assert.equal(res.body.source, 'expected-name'); + assert.equal(res.body.stores[0].storageRef.full_name, 'sam/viz_scans'); +}); + +test('POST /api/auth/storage/create uses the default store when name is omitted', async () => { + const app = createAuthedApp({ + user: AUTHED_USER, + authService: { + clientsFor: async () => ({ githubUserClient: { mock: true } }), + getInstallationSetupUrl: async () => + 'https://github.com/apps/vizably/installations/new', + }, + storageService: { + createNextVizablyGitHubRepository: async (_clients, options) => ({ + storageRef: { + id: 'R_kgNew', + name: 'viz_scans', + full_name: 'sam/viz_scans', + private: true, + html_url: 'https://github.com/sam/viz_scans', + }, + needsInstall: false, + installUrl: options.installUrl, + }), + }, + }); + const res = await request(app).post('/api/auth/storage/create').send({ provider: 'github' }); + assert.equal(res.status, 201); + assert.equal(res.body.storageRef.name, 'viz_scans'); }); test('POST /api/auth/storage/create returns probe failures without needsInstall', async () => { diff --git a/backend/tests/githubRepoName.test.js b/backend/tests/githubRepoName.test.js index 105255f..e94645a 100644 --- a/backend/tests/githubRepoName.test.js +++ b/backend/tests/githubRepoName.test.js @@ -1,9 +1,13 @@ /** - * Unit tests for shared GitHub repo name normalization (#85). + * Unit tests for shared GitHub repository name helpers. */ const test = require('node:test'); const assert = require('node:assert/strict'); -const { normalizeGitHubRepoName } = require('../../shared/githubRepoName'); +const { + normalizeGitHubRepoName, + applyVizablyRepoPrefix, + VIZABLY_REPO_PREFIX, +} = require('../../shared/githubRepoName'); test('normalizeGitHubRepoName trims leading and trailing whitespace', () => { assert.equal(normalizeGitHubRepoName(' vizably-scans '), 'vizably-scans'); @@ -23,3 +27,30 @@ test('normalizeGitHubRepoName returns empty for whitespace-only input', () => { assert.equal(normalizeGitHubRepoName(null), ''); assert.equal(normalizeGitHubRepoName(undefined), ''); }); + +test('applyVizablyRepoPrefix prepends viz_ after normalizing', () => { + assert.equal(applyVizablyRepoPrefix('scans'), 'viz_scans'); + assert.equal(applyVizablyRepoPrefix(' accessibility results '), 'viz_accessibility-results'); + assert.equal(applyVizablyRepoPrefix('reports'), `${VIZABLY_REPO_PREFIX}reports`); +}); + +test('applyVizablyRepoPrefix is idempotent when the prefix is already present', () => { + assert.equal(applyVizablyRepoPrefix('viz_scans'), 'viz_scans'); + assert.equal(applyVizablyRepoPrefix('VIZ_reports'), 'viz_reports'); + assert.equal(applyVizablyRepoPrefix(' viz_my-repo '), 'viz_my-repo'); +}); + +test('applyVizablyRepoPrefix returns empty for blank input', () => { + assert.equal(applyVizablyRepoPrefix(' '), ''); + assert.equal(applyVizablyRepoPrefix(null), ''); +}); + +test('nextVizablyStoreName starts at viz_scans then increments', () => { + const { + nextVizablyStoreName, + VIZABLY_DEFAULT_STORE_NAME, + } = require('../../shared/githubRepoName'); + assert.equal(nextVizablyStoreName([]), VIZABLY_DEFAULT_STORE_NAME); + assert.equal(nextVizablyStoreName(['viz_scans']), 'viz_scans-2'); + assert.equal(nextVizablyStoreName(['viz_scans', 'viz_scans-2']), 'viz_scans-3'); +}); diff --git a/backend/tests/storageService.test.js b/backend/tests/storageService.test.js index 883b313..b3449dc 100644 --- a/backend/tests/storageService.test.js +++ b/backend/tests/storageService.test.js @@ -95,6 +95,9 @@ function createMockGitHubClient(initial = {}) { return { data: repoMeta }; }, createForAuthenticatedUser: async ({ name, private: isPrivate, auto_init }) => { + if (typeof initial.createRepo === 'function') { + return initial.createRepo({ name, private: isPrivate, auto_init }); + } if (initial.createRepoError) { throw initial.createRepoError; } @@ -350,7 +353,8 @@ test('checkGitHubRepoNameAvailability returns available on 404', async () => { githubUserClient: client, }); assert.equal(result.status, 'available'); - assert.equal(result.full_name, 'sam/fresh-repo'); + assert.equal(result.normalizedName, 'viz_fresh-repo'); + assert.equal(result.full_name, 'sam/viz_fresh-repo'); }); test('checkGitHubRepoNameAvailability returns taken when repo exists', async () => { @@ -364,7 +368,8 @@ test('checkGitHubRepoNameAvailability returns taken when repo exists', async () githubUserClient: client, }); assert.equal(result.status, 'taken'); - assert.match(result.message, /already exists/); + assert.equal(result.normalizedName, 'viz_site-audits'); + assert.match(result.message, /viz_site-audits/); }); test('checkGitHubRepoNameAvailability returns invalid for bad names', async () => { @@ -384,14 +389,15 @@ test('createGitHubRepository creates a private empty repo and returns storageRef { id: 1, contents: 'write', - repos: ['sam/vizably-new'], + repos: ['sam/viz_scans'], }, ], }); - const result = await storageService.createGitHubRepository('vizably-new', { + const result = await storageService.createGitHubRepository('scans', { githubUserClient: client, }); - assert.equal(result.storageRef.full_name, 'sam/vizably-new'); + assert.equal(result.storageRef.full_name, 'sam/viz_scans'); + assert.equal(result.storageRef.name, 'viz_scans'); assert.equal(result.storageRef.id, 'R_kgNew'); assert.equal(result.needsInstall, false); assert.equal(result.installUrl, null); @@ -409,11 +415,12 @@ test('createGitHubRepository sets needsInstall when App cannot write yet', async ], }); const result = await storageService.createGitHubRepository( - 'vizably-new', + 'scans', { githubUserClient: client }, { installUrl: 'https://github.com/apps/vizably/installations/new' }, ); assert.equal(result.needsInstall, true); + assert.equal(result.storageRef.name, 'viz_scans'); assert.equal( result.installUrl, 'https://github.com/apps/vizably/installations/new', @@ -432,16 +439,17 @@ test('createGitHubRepository skips install hop when installation covers all repo }, ], }); - const result = await storageService.createGitHubRepository('vizably-new', { + const result = await storageService.createGitHubRepository('scans', { githubUserClient: client, }); assert.equal(result.needsInstall, false); + assert.equal(result.storageRef.name, 'viz_scans'); }); test('createGitHubRepository finds writable install when repo is past first page', async () => { const storageService = new StorageService(); const repos = Array.from({ length: 101 }, (_, i) => - i === 100 ? 'sam/vizably-new' : `sam/other-${i}`, + i === 100 ? 'sam/viz_paged' : `sam/other-${i}`, ); const client = createMockGitHubClient({ installationProbe: [ @@ -452,10 +460,11 @@ test('createGitHubRepository finds writable install when repo is past first page }, ], }); - const result = await storageService.createGitHubRepository('vizably-new', { + const result = await storageService.createGitHubRepository('paged', { githubUserClient: client, }); assert.equal(result.needsInstall, false); + assert.equal(result.storageRef.full_name, 'sam/viz_paged'); }); test('createGitHubRepository finds writable install when installation is past first page', async () => { @@ -463,13 +472,14 @@ test('createGitHubRepository finds writable install when installation is past fi const installationProbe = Array.from({ length: 101 }, (_, i) => ({ id: i + 1, contents: 'write', - repos: i === 100 ? ['sam/vizably-new'] : [`sam/other-${i}`], + repos: i === 100 ? ['sam/viz_paged'] : [`sam/other-${i}`], })); const client = createMockGitHubClient({ installationProbe }); - const result = await storageService.createGitHubRepository('vizably-new', { + const result = await storageService.createGitHubRepository('paged', { githubUserClient: client, }); assert.equal(result.needsInstall, false); + assert.equal(result.storageRef.full_name, 'sam/viz_paged'); }); test('createGitHubRepository surfaces rate limits instead of needsInstall', async () => { @@ -482,13 +492,13 @@ test('createGitHubRepository surfaces rate limits instead of needsInstall', asyn }; const client = createMockGitHubClient({ installationProbeError: probeErr }); await assert.rejects( - () => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }), + () => storageService.createGitHubRepository('scans', { githubUserClient: client }), (err) => { assert.equal(err.code, 'GITHUB_RATE_LIMITED'); assert.equal(err.status, 429); assert.match(err.message, /rate-limited/i); assert.match(err.message, /do not reinstall/i); - assert.equal(err.storageRef?.full_name, 'sam/vizably-new'); + assert.equal(err.storageRef?.full_name, 'sam/viz_scans'); return true; }, ); @@ -500,12 +510,12 @@ test('createGitHubRepository surfaces network failures instead of needsInstall', probeErr.code = 'ENOTFOUND'; const client = createMockGitHubClient({ installationProbeError: probeErr }); await assert.rejects( - () => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }), + () => storageService.createGitHubRepository('scans', { githubUserClient: client }), (err) => { assert.equal(err.code, 'GITHUB_NETWORK_ERROR'); assert.equal(err.status, 503); assert.match(err.message, /network/i); - assert.equal(err.storageRef?.name, 'vizably-new'); + assert.equal(err.storageRef?.name, 'viz_scans'); return true; }, ); @@ -518,7 +528,7 @@ test('createGitHubRepository surfaces auth failures instead of needsInstall', as probeErr.response = { data: { message: 'Bad credentials' }, headers: {} }; const client = createMockGitHubClient({ installationProbeError: probeErr }); await assert.rejects( - () => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }), + () => storageService.createGitHubRepository('scans', { githubUserClient: client }), (err) => { assert.equal(err.code, 'GITHUB_AUTH_FAILED'); assert.equal(err.status, 401); @@ -535,7 +545,7 @@ test('createGitHubRepository surfaces GitHub outages instead of needsInstall', a probeErr.response = { data: { message: 'Server Error' }, headers: {} }; const client = createMockGitHubClient({ installationProbeError: probeErr }); await assert.rejects( - () => storageService.createGitHubRepository('vizably-new', { githubUserClient: client }), + () => storageService.createGitHubRepository('scans', { githubUserClient: client }), (err) => { assert.equal(err.code, 'GITHUB_UNAVAILABLE'); assert.equal(err.status, 503); @@ -549,7 +559,7 @@ test('createGitHubRepository rejects invalid names', async () => { const storageService = new StorageService(); const client = createMockGitHubClient(); await assert.rejects( - () => storageService.createGitHubRepository('sam/vizably-new', { githubUserClient: client }), + () => storageService.createGitHubRepository('sam/scans', { githubUserClient: client }), /name only/, ); await assert.rejects( @@ -562,22 +572,40 @@ test('createGitHubRepository rejects invalid names', async () => { ); }); -test('createGitHubRepository normalizes whitespace before create', async () => { +test('createGitHubRepository prefixes and normalizes whitespace before create', async () => { const storageService = new StorageService(); const client = createMockGitHubClient({ installationProbe: [ { id: 1, contents: 'write', - repos: ['sam/vizably-new'], + repos: ['sam/viz_accessibility-results'], }, ], }); - const result = await storageService.createGitHubRepository(' vizably new ', { + const result = await storageService.createGitHubRepository(' accessibility results ', { githubUserClient: client, }); - assert.equal(result.storageRef.full_name, 'sam/vizably-new'); - assert.equal(result.storageRef.name, 'vizably-new'); + assert.equal(result.storageRef.full_name, 'sam/viz_accessibility-results'); + assert.equal(result.storageRef.name, 'viz_accessibility-results'); +}); + +test('createGitHubRepository does not double-prefix an existing viz_ name', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + installationProbe: [ + { + id: 1, + contents: 'write', + repository_selection: 'all', + repos: [], + }, + ], + }); + const result = await storageService.createGitHubRepository('viz_reports', { + githubUserClient: client, + }); + assert.equal(result.storageRef.name, 'viz_reports'); }); test('createGitHubRepository maps name-taken conflicts', async () => { @@ -593,7 +621,7 @@ test('createGitHubRepository maps name-taken conflicts', async () => { const client = createMockGitHubClient({ createRepoError: conflict }); await assert.rejects( () => storageService.createGitHubRepository('taken', { githubUserClient: client }), - /already exists/, + /viz_taken/, ); }); @@ -1370,3 +1398,229 @@ test('deleteScanById stubs google until Phase 3', async () => { (err) => err.status === 501 && err.code === 'PROVIDER_NOT_AVAILABLE', ); }); + +test('discoverAccountStores returns the session store first', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'vizably.json': { content: JSON.stringify(manifest()), sha: 'sha-manifest' }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + }, + }); + const result = await storageService.discoverAccountStores( + 'github', + { githubClient: client, githubUserClient: client }, + { sessionStorageRef: STORAGE_REF }, + ); + assert.equal(result.source, 'session'); + assert.equal(result.stores.length, 1); + assert.equal(result.stores[0].storageRef.full_name, STORAGE_REF.full_name); + assert.equal(result.stores[0].validation.status, 'loadable'); +}); + +test('discoverAccountStores uses GET viz_scans before listing', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'vizably.json': { content: JSON.stringify(manifest()), sha: 'sha-manifest' }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + }, + listedRepos: [], + repoGet: async ({ repo }) => { + if (repo !== 'viz_scans') { + const err = new Error('Not Found'); + err.status = 404; + throw err; + } + return { + data: { + node_id: 'R_kgScans', + name: 'viz_scans', + full_name: 'sam/viz_scans', + html_url: 'https://github.com/sam/viz_scans', + default_branch: 'main', + permissions: { pull: true, push: true, admin: false }, + }, + }; + }, + }); + const result = await storageService.discoverAccountStores('github', { + githubClient: client, + githubUserClient: client, + }); + assert.equal(result.source, 'expected-name'); + assert.equal(result.stores.length, 1); + assert.equal(result.stores[0].storageRef.full_name, 'sam/viz_scans'); +}); + +test('discoverAccountStores lists repos when expected name is not a store', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'vizably.json': { content: JSON.stringify(manifest()), sha: 'sha-manifest' }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + }, + listedRepos: [ + { + node_id: STORAGE_REF.id, + full_name: STORAGE_REF.full_name, + private: true, + html_url: STORAGE_REF.html_url, + }, + ], + repoGet: async ({ repo }) => { + if (repo === 'viz_scans') { + const err = new Error('Not Found'); + err.status = 404; + throw err; + } + return { + data: { + default_branch: 'main', + permissions: { pull: true, push: true, admin: false }, + name: repo, + full_name: `sam/${repo}`, + }, + }; + }, + }); + const result = await storageService.discoverAccountStores('github', { + githubClient: client, + githubUserClient: client, + }); + assert.equal(result.source, 'list'); + assert.equal(result.stores.length, 1); + assert.equal(result.stores[0].storageRef.id, STORAGE_REF.id); +}); + +test('discoverAccountStores ignores listed repos that have no manifest', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: {}, + listedRepos: [ + { + node_id: STORAGE_REF.id, + full_name: STORAGE_REF.full_name, + private: true, + html_url: STORAGE_REF.html_url, + }, + ], + repoGet: async ({ repo }) => { + if (repo === 'viz_scans') { + const err = new Error('Not Found'); + err.status = 404; + throw err; + } + return { + data: { + default_branch: 'main', + permissions: { pull: true, push: true, admin: false }, + name: repo, + full_name: `sam/${repo}`, + }, + }; + }, + }); + const result = await storageService.discoverAccountStores('github', { + githubClient: client, + githubUserClient: client, + }); + assert.equal(result.source, 'list'); + assert.equal(result.stores.length, 0); +}); + +test('discoverAccountStores returns every listed store with a manifest', async () => { + const storageService = new StorageService(); + const client = createMockGitHubClient({ + files: { + 'vizably.json': { content: JSON.stringify(manifest()), sha: 'sha-manifest' }, + 'scans/index.json': { + content: JSON.stringify({ schemaVersion: 1, scans: [] }), + sha: 'sha-index', + }, + }, + listedRepos: [ + { + node_id: 'R_one', + full_name: 'sam/vizably-scans', + private: true, + html_url: 'https://github.com/sam/vizably-scans', + }, + { + node_id: 'R_two', + full_name: 'sam/viz_scans-2', + private: true, + html_url: 'https://github.com/sam/viz_scans-2', + }, + ], + repoGet: async ({ repo }) => { + if (repo === 'viz_scans') { + const err = new Error('Not Found'); + err.status = 404; + throw err; + } + return { + data: { + default_branch: 'main', + permissions: { pull: true, push: true, admin: false }, + name: repo, + full_name: `sam/${repo}`, + }, + }; + }, + }); + const result = await storageService.discoverAccountStores('github', { + githubClient: client, + githubUserClient: client, + }); + assert.equal(result.source, 'list'); + assert.equal(result.stores.length, 2); +}); + +test('createNextVizablyGitHubRepository falls back to viz_scans-2 when taken', async () => { + const storageService = new StorageService(); + const created = []; + const client = createMockGitHubClient({ + installationProbe: [ + { id: 1, contents: 'write', repository_selection: 'all', repos: [] }, + ], + createRepo: async ({ name, private: isPrivate }) => { + created.push(name); + if (name === 'viz_scans') { + const err = new Error('Repository creation failed.'); + err.status = 422; + err.response = { + data: { + message: 'Repository creation failed.', + errors: [{ message: 'name already exists on this account' }], + }, + }; + throw err; + } + return { + data: { + node_id: 'R_kg2', + name, + full_name: `sam/${name}`, + private: isPrivate !== false, + html_url: `https://github.com/sam/${name}`, + }, + }; + }, + }); + const result = await storageService.createNextVizablyGitHubRepository({ + githubUserClient: client, + }); + assert.deepEqual(created, ['viz_scans', 'viz_scans-2']); + assert.equal(result.storageRef.name, 'viz_scans-2'); + assert.equal(result.needsInstall, false); +}); diff --git a/docs/guides/auth_storage_guide/TODO.md b/docs/guides/auth_storage_guide/TODO.md index 4e700c9..de70944 100644 --- a/docs/guides/auth_storage_guide/TODO.md +++ b/docs/guides/auth_storage_guide/TODO.md @@ -7,7 +7,8 @@ > Verify each item against the actual code before ticking it. > > **Model in one line:** the user's GitHub repo / Drive folder *is* the account. -> Flow is **browse → select → validate (fit-check) → load or init**. No project DB. +> Flow is **sign in → discover or create → validate (fit-check) → load or init**. +> No project DB. GitHub Connect is one action (no name field / repo picker). --- @@ -156,17 +157,21 @@ Provider-neutral API shape; **GitHub picker wired**, Google deferred to Phase 3. - [x] Keep `runScan`, `getScanResults`, `getProblem` - [x] `deleteScan(id)` → `DELETE /api/scans/:id` -### ConnectView (`frontend/src/views/ConnectView.jsx`) — the picker +### ConnectView (`frontend/src/views/ConnectView.jsx`) -- [x] GitHub: list repos via `listStorages('github')` +GitHub Connect is one action. Vizably discovers or creates the store; the user +does not name or pick a repository in the normal flow. + +- [x] Discover stores via `GET /api/auth/storage/discover` (session ref → + `viz_scans` GET → list + `vizably.json`) +- [x] No repository name field; no "Use an existing repository" picker +- [x] Zero stores → one button creates `viz_scans` / `viz_scans-2` / … and inits +- [x] One store → load or init automatically selected +- [x] Two or more stores → chooser for that ambiguity only - [x] Google Picker deferred to Phase 3 (hide or disable Google connect path) -- [x] Persistent **"Create new"** option (the `init` path on a fresh store) -- [x] On select → `validateStorage` → render fit-check status + scan count -- [x] Action button follows status: `loadable`→"Load my account", - `initializable`/new→"Set up & continue", `incompatible`/`invalid`→blocked + guidance +- [x] Action button follows fit-check status - [x] Disable init when `capabilities.canWrite === false` - [x] On confirm → `setupStorage(provider, storageRef, action)` → dashboard -- [x] Replace hard-coded `existing` lists in `frontend/src/data/placeholders.js` - [x] `ConnectView` — accept a `storageError` prop for failures ### App Routes (`frontend/src/App.jsx`) diff --git a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md index 7b07da6..6431aa4 100644 --- a/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md +++ b/docs/guides/auth_storage_guide/githubGoogleAuthStorageImplementation.md @@ -15,10 +15,12 @@ account travels with the user's repo/folder, they can sign in from any device, server-side data, no lock-in, no per-user hosting cost. That is what makes it cheap to offer to as many people as possible. -The defining UX is **browse → select → validate → load-or-init**: +The defining UX is **sign in → discover or create → load-or-init**: 1. The user connects GitHub or Google (OAuth). -2. They **see the repos/folders they already have** and **select one**. +2. Vizably **discovers** an existing account store (`vizably.json`) or + **creates** the next default location (`viz_scans`, then `viz_scans-2`, …). + A chooser appears only when two or more stores are found. 3. vizably **validates** whether that storage *fits the data needed to load the account back* (a "fit-check"). 4. Depending on the result, vizably **loads** the existing account or @@ -51,10 +53,10 @@ Provider redirects to /api/auth/{provider}/callback with auth code Backend exchanges code for tokens → encrypts tokens → stores in session │ ▼ -User lands on ConnectView — the storage picker +User lands on ConnectView — one Connect action │ - ├─ GitHub: backend lists the user's repos → user selects one - └─ Google: Google Picker lists Drive folders → user selects one + ├─ GitHub: discover vizably.json (or create viz_scans / viz_scans-2 / …) + └─ Google: same resolution model via Picker (Phase 3) │ ▼ Backend VALIDATES the selected storage (fit-check) @@ -81,9 +83,11 @@ scans/index scans/ dir (init or cancel) On scan: results appended to the user's storage (atomic write) ``` -The user can also choose **"Create a new repo/folder"** instead of selecting an -existing one — that is just the `initializable` path against a freshly created -store. +Connect does not ask the user to name or pick a repository. Vizably discovers +an existing account store by looking for `vizably.json`, or creates `viz_scans` +(then `viz_scans-2`, `viz_scans-3`, … if that name is taken). A chooser appears +only when discovery finds two or more stores. GitHub's own permissions remain +the account ACL — Vizably does not add an extra ownership check. ### Identity model — read this first @@ -364,20 +368,20 @@ Session cookies, not Bearer tokens. All calls use `credentials: 'include'`; no For Google, selection is done with the **Google Picker** client library; the chosen folder id flows into `validateStorage` / `setupStorage`. -### ConnectView — the picker (`frontend/src/views/ConnectView.jsx`) +### ConnectView (`frontend/src/views/ConnectView.jsx`) -Today this is a placeholder with hard-coded `existing` lists in -`frontend/src/data/placeholders.js`. The real ConnectView: +Connect is one action. Vizably discovers an existing account store by looking +for `vizably.json` (never by matching the repo name), or creates `viz_scans` +(`viz_scans-2`, … if taken). There is no name field and no repository picker +in the normal flow. A chooser appears only when discovery finds two or more +stores. -1. **Lists real storage.** GitHub → `listStorages('github')`. Google → launch - Google Picker. Plus a persistent **"Create new"** option. -2. **Validates on select.** On pick, call `validateStorage` and render the - fit-check result (status copy + scan count when `loadable`). -3. **Offers the right action.** Button text follows status: - `loadable` → "Load my account", `initializable`/new → "Set up & continue", - `incompatible`/`invalid` → blocked with guidance. -4. **Confirms.** `setupStorage(provider, storageRef, action)` → on success the - account (with `storage`) is attached; navigate to the dashboard. +1. **Discover.** `discoverStorages('github')` — session `storageRef`, then GET + `viz_scans`, then list repos as a fallback. +2. **Zero stores.** One button creates the next default name and inits it. +3. **One store.** Fit-check copy + "Load my account" / "Set up & continue". +4. **Two or more.** Chooser of discovered stores, then the same confirm action. +5. **Confirms.** `setupStorage(provider, storageRef, action)` → dashboard. ### App routes (`frontend/src/App.jsx`) @@ -536,14 +540,14 @@ repo without asking the user to leave and create one manually. ### GitHub (Octokit) ```js -// List repos the user can write (for the picker) +// List repos (discovery fallback when session ref and viz_scans GET miss) await octokit.repos.listForAuthenticatedUser({ visibility: 'all', per_page: 100 }); // Existence / fit-check read — get the manifest blob await octokit.repos.getContent({ owner, repo, path: 'vizably.json' }); // 404 ⇒ no manifest -// Create a repo for the "new" path -await octokit.repos.createForAuthenticatedUser({ name, private: true }); +// Create a repo for the "new" path — name is always `viz_` +await octokit.repos.createForAuthenticatedUser({ name: 'viz_scans', private: true }); // Atomic-ish write (pass sha to update; omit to create) await octokit.repos.createOrUpdateFileContents({ owner, repo, path, message, content, branch, sha }); diff --git a/frontend/src/__tests__/apiClient.test.js b/frontend/src/__tests__/apiClient.test.js index 7a05ebf..1fd991c 100644 --- a/frontend/src/__tests__/apiClient.test.js +++ b/frontend/src/__tests__/apiClient.test.js @@ -99,6 +99,33 @@ describe('ApiClient', () => { ) }) + it('discoverStorages calls GET /api/auth/storage/discover', async () => { + const fetchImpl = mockFetch({ provider: 'github', stores: [], source: 'list' }) + const client = new ApiClient({ fetchImpl }) + + await client.discoverStorages('github') + + expect(fetchImpl).toHaveBeenCalledWith( + '/api/auth/storage/discover?provider=github', + expect.any(Object), + ) + }) + + it('createStorage omits name for the default store', async () => { + const fetchImpl = mockFetch({ provider: 'github', storageRef: { name: 'viz_scans' } }) + const client = new ApiClient({ fetchImpl }) + + await client.createStorage() + + expect(fetchImpl).toHaveBeenCalledWith( + '/api/auth/storage/create', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ provider: 'github' }), + }), + ) + }) + it('validateStorage posts provider and storageRef', async () => { const fetchImpl = mockFetch({ status: 'loadable' }) const client = new ApiClient({ fetchImpl }) diff --git a/frontend/src/__tests__/connectView.test.jsx b/frontend/src/__tests__/connectView.test.jsx index 3d91315..5ebf852 100644 --- a/frontend/src/__tests__/connectView.test.jsx +++ b/frontend/src/__tests__/connectView.test.jsx @@ -2,40 +2,40 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import ConnectView from '../views/ConnectView' -const REPO = { - id: 'R_kg', - name: 'site-audits', - full_name: 'sam/site-audits', - private: true, - html_url: 'https://github.com/sam/site-audits', +const LOADABLE = { + status: 'loadable', + reason: null, + capabilities: { canRead: true, canWrite: true, canCreate: false }, + manifestSummary: { scanCount: 3, schemaVersion: 1, accountId: 'a1' }, +} + +const STORE = { + storageRef: { + id: 'R_kg', + name: 'vizably-scans', + full_name: 'sam/vizably-scans', + html_url: 'https://github.com/sam/vizably-scans', + }, + validation: LOADABLE, } function mockClient(overrides = {}) { return { - listStorages: vi.fn().mockResolvedValue({ provider: 'github', storages: [REPO] }), - validateStorage: vi.fn().mockResolvedValue({ - status: 'loadable', - reason: null, - capabilities: { canRead: true, canWrite: true, canCreate: false }, - manifestSummary: { scanCount: 3, schemaVersion: 1, accountId: 'a1' }, + discoverStorages: vi.fn().mockResolvedValue({ + provider: 'github', + stores: [STORE], + source: 'list', }), + validateStorage: vi.fn().mockResolvedValue(LOADABLE), setupStorage: vi.fn().mockResolvedValue({ success: true }), - checkRepoNameAvailability: vi.fn().mockImplementation(async (name) => ({ - provider: 'github', - name, - normalizedName: name.trim(), - full_name: `sam/${name.trim()}`, - status: 'available', - message: `sam/${name.trim()} is available.`, - })), createStorage: vi.fn().mockResolvedValue({ provider: 'github', storageRef: { id: 'R_kgNew', - name: 'vizably-new', - full_name: 'sam/vizably-new', + name: 'viz_scans', + full_name: 'sam/viz_scans', private: true, - html_url: 'https://github.com/sam/vizably-new', + html_url: 'https://github.com/sam/viz_scans', }, needsInstall: false, installUrl: null, @@ -44,19 +44,6 @@ function mockClient(overrides = {}) { } } -async function typeNewRepoName(value) { - fireEvent.click(screen.getByText(/Create a new repository/i)) - const input = screen.getByDisplayValue('vizably-scans') - fireEvent.change(input, { target: { value } }) - return input -} - -async function waitForRepoPicker(client) { - await screen.findByText('sam/site-audits (private)', {}, { timeout: 3000 }) - await waitFor(() => expect(client.listStorages).toHaveBeenCalledWith('github')) - await waitFor(() => expect(client.validateStorage).toHaveBeenCalled()) -} - describe('ConnectView', () => { beforeEach(() => { vi.stubGlobal('location', { href: 'http://localhost:5173/connect' }) @@ -66,74 +53,86 @@ describe('ConnectView', () => { vi.unstubAllGlobals() }) - it('loads GitHub repos and validates the selected repo', async () => { + it('discovers a store and offers load without a name field or repo picker', async () => { const client = mockClient() render( - , + , ) - await waitForRepoPicker(client) - expect(client.validateStorage).toHaveBeenCalledWith('github', { - id: REPO.id, - full_name: REPO.full_name, - html_url: REPO.html_url, - }) - expect(screen.getByText('Vizably account found')).toBeInTheDocument() + await waitFor(() => expect(client.discoverStorages).toHaveBeenCalledWith('github')) + expect(await screen.findByText('Vizably account found')).toBeInTheDocument() expect(screen.getByText('3 saved scans')).toBeInTheDocument() + expect(screen.queryByPlaceholderText('scans')).not.toBeInTheDocument() + expect(screen.queryByText(/Use an existing/i)).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /load my account/i })).toBeInTheDocument() }) - it('calls setupStorage with load on confirm for loadable storage', async () => { + it('calls setupStorage with load on confirm', async () => { const onDone = vi.fn() const client = mockClient() render( , ) - await waitForRepoPicker(client) expect(await screen.findByText('Vizably account found')).toBeInTheDocument() - - const button = screen.getByRole('button', { name: /load my account/i }) - fireEvent.click(button) + fireEvent.click(screen.getByRole('button', { name: /load my account/i })) await waitFor(() => expect(client.setupStorage).toHaveBeenCalledWith( 'github', - expect.objectContaining({ id: REPO.id, full_name: REPO.full_name }), + expect.objectContaining({ id: STORE.storageRef.id, full_name: STORE.storageRef.full_name }), 'load', ), ) expect(onDone).toHaveBeenCalled() }) - it('offers set up & continue for initializable storage', async () => { + it('creates viz_scans and inits when no store exists', async () => { + const onDone = vi.fn() const client = mockClient({ - validateStorage: vi.fn().mockResolvedValue({ - status: 'initializable', - capabilities: { canRead: true, canWrite: true, canCreate: true }, + discoverStorages: vi.fn().mockResolvedValue({ + provider: 'github', + stores: [], + source: 'list', }), }) render( - , + , ) - await waitForRepoPicker(client) - expect(await screen.findByText('Ready to set up')).toBeInTheDocument() - expect( - screen.getByRole('button', { name: /set up & continue/i }), - ).toBeInTheDocument() + expect(await screen.findByRole('button', { name: /set up vizably storage/i })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /set up vizably storage/i })) + + await waitFor(() => expect(client.createStorage).toHaveBeenCalledWith()) + await waitFor(() => + expect(client.setupStorage).toHaveBeenCalledWith( + 'github', + expect.objectContaining({ id: 'R_kgNew', full_name: 'sam/viz_scans' }), + 'init', + ), + ) + expect(onDone).toHaveBeenCalled() }) - it('blocks init when storage is not writable', async () => { + it('shows a chooser only when two stores are discovered', async () => { + const second = { + storageRef: { + id: 'R_two', + name: 'viz_scans', + full_name: 'sam/viz_scans', + html_url: 'https://github.com/sam/viz_scans', + }, + validation: { + ...LOADABLE, + manifestSummary: { scanCount: 1, schemaVersion: 1, accountId: 'a2' }, + }, + } const client = mockClient({ - validateStorage: vi.fn().mockResolvedValue({ - status: 'initializable', - capabilities: { canRead: true, canWrite: false, canCreate: false }, + discoverStorages: vi.fn().mockResolvedValue({ + provider: 'github', + stores: [STORE, second], + source: 'list', }), }) @@ -141,20 +140,24 @@ describe('ConnectView', () => { , ) - await waitForRepoPicker(client) - expect(await screen.findByText('Ready to set up')).toBeInTheDocument() - - const button = screen.getByRole('button', { name: /set up & continue/i }) - expect(button).toBeDisabled() - expect(screen.getByText(/read-only/i)).toBeInTheDocument() + expect(await screen.findByLabelText(/choose vizably storage/i)).toBeInTheDocument() + expect(screen.getByText(/more than one vizably store/i)).toBeInTheDocument() }) - it('blocks incompatible storage', async () => { + it('blocks init when storage is not writable', async () => { const client = mockClient({ - validateStorage: vi.fn().mockResolvedValue({ - status: 'incompatible', - reason: 'too_new', - capabilities: { canRead: true, canWrite: true, canCreate: false }, + discoverStorages: vi.fn().mockResolvedValue({ + provider: 'github', + stores: [ + { + storageRef: STORE.storageRef, + validation: { + status: 'initializable', + capabilities: { canRead: true, canWrite: false, canCreate: false }, + }, + }, + ], + source: 'list', }), }) @@ -162,11 +165,9 @@ describe('ConnectView', () => { , ) - await waitForRepoPicker(client) - expect(await screen.findByText('Update Vizably required')).toBeInTheDocument() - - const button = screen.getByRole('button', { name: /continue/i }) - expect(button).toBeDisabled() + expect(await screen.findByText('Ready to set up')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /set up & continue/i })).toBeDisabled() + expect(screen.getByText(/read-only/i)).toBeInTheDocument() }) it('shows Google deferred message for google provider', () => { @@ -179,7 +180,7 @@ describe('ConnectView', () => { }) it('renders storageError prop', async () => { - const client = mockClient({ listStorages: vi.fn().mockRejectedValue(new Error('nope')) }) + const client = mockClient() render( { ) expect(screen.getByRole('alert')).toHaveTextContent('GitHub sign-in failed') + await waitFor(() => expect(client.discoverStorages).toHaveBeenCalled()) }) - it('creates a new repository then validates for init', async () => { - const created = { - id: 'R_kgNew', - name: 'vizably-new', - full_name: 'sam/vizably-new', - private: true, - html_url: 'https://github.com/sam/vizably-new', - } - const client = mockClient({ - createStorage: vi.fn().mockResolvedValue({ - provider: 'github', - storageRef: created, - needsInstall: false, - installUrl: null, - }), - validateStorage: vi.fn().mockResolvedValue({ - status: 'initializable', - capabilities: { canRead: true, canWrite: true, canCreate: true }, - }), - }) - - render( - , - ) - - await waitForRepoPicker(client) - - await typeNewRepoName('vizably-new') - expect(await screen.findByText(/is available/i)).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: /create repository/i })) - - await waitFor(() => expect(client.createStorage).toHaveBeenCalledWith('vizably-new')) - await waitFor(() => - expect(client.validateStorage).toHaveBeenCalledWith( - 'github', - expect.objectContaining({ id: 'R_kgNew', full_name: 'sam/vizably-new' }), - ), - ) - expect(await screen.findByText('Ready to set up')).toBeInTheDocument() - }) - - it('shows probe failure message without install CTA', async () => { - const err = new Error( - 'GitHub rate-limited the request while checking App installation access. Wait a moment and refresh — do not reinstall the Vizably GitHub App.', - ) - err.code = 'GITHUB_RATE_LIMITED' - err.storageRef = { - id: 'R_kgNew', - name: 'vizably-new', - full_name: 'sam/vizably-new', - private: true, - html_url: 'https://github.com/sam/vizably-new', - } - const client = mockClient({ - createStorage: vi.fn().mockRejectedValue(err), - }) - - render( - , - ) - - await waitForRepoPicker(client) - fireEvent.click(screen.getByText(/Create a new repository/i)) - fireEvent.change(screen.getByDisplayValue('vizably-scans'), { - target: { value: 'vizably-new' }, - }) - // Availability must resolve so Create is enabled. - expect(await screen.findByText(/is available/i)).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /create repository/i })) - - expect(await screen.findByRole('alert')).toHaveTextContent(/do not reinstall/i) - expect(screen.queryByText(/Open GitHub App install/i)).not.toBeInTheDocument() - }) - - it('normalizes whitespace in the repository name before create', async () => { - const client = mockClient({ - createStorage: vi.fn().mockResolvedValue({ - provider: 'github', - storageRef: { - id: 'R_kgNew', - name: 'vizably-new', - full_name: 'sam/vizably-new', - private: true, - html_url: 'https://github.com/sam/vizably-new', - }, - needsInstall: false, - installUrl: null, - }), - validateStorage: vi.fn().mockResolvedValue({ - status: 'initializable', - capabilities: { canRead: true, canWrite: true, canCreate: true }, - }), - checkRepoNameAvailability: vi.fn().mockResolvedValue({ - provider: 'github', - name: 'vizably-new', - normalizedName: 'vizably-new', - full_name: 'sam/vizably-new', - status: 'available', - message: 'sam/vizably-new is available.', - }), - }) - - render( - , - ) - - await waitForRepoPicker(client) - fireEvent.click(screen.getByText(/Create a new repository/i)) - fireEvent.change(screen.getByDisplayValue('vizably-scans'), { - target: { value: ' vizably new ' }, - }) - expect(await screen.findByText(/is available/i)).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /create repository/i })) - - await waitFor(() => expect(client.createStorage).toHaveBeenCalledWith('vizably-new')) - expect(screen.getByDisplayValue('vizably-new')).toBeInTheDocument() - }) - - it('keeps focus on the repository name input while typing', async () => { - const client = mockClient() - render( - , - ) - - await waitForRepoPicker(client) - fireEvent.click(screen.getByText(/Create a new repository/i)) - - const input = screen.getByDisplayValue('vizably-scans') - input.focus() - expect(document.activeElement).toBe(input) - - fireEvent.change(input, { target: { value: 'v' } }) - expect(document.activeElement).toBe(input) - fireEvent.change(input, { target: { value: 'vi' } }) - expect(document.activeElement).toBe(input) - fireEvent.change(input, { target: { value: 'viz' } }) - expect(document.activeElement).toBe(input) - expect(input).toHaveValue('viz') - }) - - it('shows taken status and blocks create for an existing name', async () => { + it('shows install hop when create returns needsInstall', async () => { const client = mockClient({ - // Name is taken on GitHub but not in the local picker list. - checkRepoNameAvailability: vi.fn().mockResolvedValue({ + discoverStorages: vi.fn().mockResolvedValue({ provider: 'github', - name: 'already-taken', - normalizedName: 'already-taken', - full_name: 'sam/already-taken', - status: 'taken', - message: 'A repository named "already-taken" already exists on your account.', + stores: [], + source: 'list', }), - }) - - render( - , - ) - - await waitForRepoPicker(client) - await typeNewRepoName('already-taken') - - expect(await screen.findByText(/already exists/i)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /create repository/i })).toBeDisabled() - expect(client.createStorage).not.toHaveBeenCalled() - }) - - it('shows install hop when create returns needsInstall', async () => { - const client = mockClient({ createStorage: vi.fn().mockResolvedValue({ provider: 'github', storageRef: { id: 'R_kgNew', - name: 'vizably-new', - full_name: 'sam/vizably-new', + name: 'viz_scans', + full_name: 'sam/viz_scans', private: true, - html_url: 'https://github.com/sam/vizably-new', + html_url: 'https://github.com/sam/viz_scans', }, needsInstall: true, installUrl: 'https://github.com/apps/vizably/installations/new', @@ -379,15 +220,13 @@ describe('ConnectView', () => { , ) - await waitForRepoPicker(client) - await typeNewRepoName('vizably-new') - expect(await screen.findByText(/is available/i)).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /create repository/i })) + fireEvent.click(await screen.findByRole('button', { name: /set up vizably storage/i })) expect(await screen.findByText(/Open GitHub App install/i)).toHaveAttribute( 'href', 'https://github.com/apps/vizably/installations/new', ) expect(screen.getByText(/I've added it — refresh/i)).toBeInTheDocument() + expect(client.createStorage).toHaveBeenCalledWith() }) }) diff --git a/frontend/src/__tests__/githubRepoName.test.js b/frontend/src/__tests__/githubRepoName.test.js index d99ce4a..b1fe614 100644 --- a/frontend/src/__tests__/githubRepoName.test.js +++ b/frontend/src/__tests__/githubRepoName.test.js @@ -1,5 +1,11 @@ -import { describe, it, expect } from 'vitest' -import { normalizeGitHubRepoName } from '../utils/githubRepoName' +import { describe, expect, it } from 'vitest' +import { + applyVizablyRepoPrefix, + normalizeGitHubRepoName, + nextVizablyStoreName, + VIZABLY_DEFAULT_STORE_NAME, + VIZABLY_REPO_PREFIX, +} from '../utils/githubRepoName' describe('normalizeGitHubRepoName', () => { it('trims leading and trailing whitespace', () => { @@ -19,3 +25,26 @@ describe('normalizeGitHubRepoName', () => { expect(normalizeGitHubRepoName(' \t ')).toBe('') }) }) + +describe('applyVizablyRepoPrefix', () => { + it('prepends viz_ after normalizing', () => { + expect(applyVizablyRepoPrefix('scans')).toBe('viz_scans') + expect(applyVizablyRepoPrefix(' accessibility results ')).toBe( + 'viz_accessibility-results', + ) + expect(applyVizablyRepoPrefix('reports')).toBe(`${VIZABLY_REPO_PREFIX}reports`) + }) + + it('does not double-prefix when viz_ is already present', () => { + expect(applyVizablyRepoPrefix('viz_scans')).toBe('viz_scans') + expect(applyVizablyRepoPrefix('VIZ_reports')).toBe('viz_reports') + }) +}) + +describe('nextVizablyStoreName', () => { + it('uses viz_scans then numbered fallbacks', () => { + expect(nextVizablyStoreName([])).toBe(VIZABLY_DEFAULT_STORE_NAME) + expect(nextVizablyStoreName(['viz_scans'])).toBe('viz_scans-2') + expect(nextVizablyStoreName(['viz_scans', 'viz_scans-2'])).toBe('viz_scans-3') + }) +}) diff --git a/frontend/src/data/placeholders.js b/frontend/src/data/placeholders.js index 76caa6f..0b2dc65 100644 --- a/frontend/src/data/placeholders.js +++ b/frontend/src/data/placeholders.js @@ -9,7 +9,7 @@ export const PROVIDERS = { name: 'GitHub', store: 'a private GitHub repo', storeShort: 'GitHub repo', - dest: 'vizably-scans', + dest: 'scans', destIcon: 'GitBranch', unit: 'repository', unitShort: 'repo', diff --git a/frontend/src/lib/apiClient.js b/frontend/src/lib/apiClient.js index ba8756e..3bb4634 100644 --- a/frontend/src/lib/apiClient.js +++ b/frontend/src/lib/apiClient.js @@ -105,6 +105,21 @@ export class ApiClient { ) } + /** + * Discover existing Vizably account stores (manifest-based). + * @param {'github' | 'google'} provider + * @returns {Promise<{ + * provider: string, + * stores: Array<{ storageRef: object, validation: object }>, + * source: string | null, + * }>} + */ + discoverStorages(provider) { + return this._request( + `/api/auth/storage/discover?provider=${encodeURIComponent(provider)}`, + ) + } + /** * Check whether a GitHub repository name is available for the signed-in user. * @param {string} name @@ -125,7 +140,8 @@ export class ApiClient { /** * Create a private empty GitHub repository for storage onboarding. - * @param {string} name repository name (not owner/name) + * Omit `name` to use the next default (`viz_scans`, then `viz_scans-2`, …). + * @param {string} [name] repository name (not owner/name) * @returns {Promise<{ * provider: string, * storageRef: object, @@ -137,7 +153,9 @@ export class ApiClient { return this._request('/api/auth/storage/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider: 'github', name }), + body: JSON.stringify( + name ? { provider: 'github', name } : { provider: 'github' }, + ), }) } diff --git a/frontend/src/utils/githubRepoName.js b/frontend/src/utils/githubRepoName.js index 4474253..629f485 100644 --- a/frontend/src/utils/githubRepoName.js +++ b/frontend/src/utils/githubRepoName.js @@ -12,3 +12,49 @@ export function normalizeGitHubRepoName(name) { .replace(/-{2,}/g, '-') .replace(/^-+|-+$/g, '') } + +/** Prefix applied to every repository Vizably creates. */ +export const VIZABLY_REPO_PREFIX = 'viz_' + +/** First-choice store name for a new account (`viz_scans`, then `viz_scans-2`, …). */ +export const VIZABLY_DEFAULT_STORE_NAME = `${VIZABLY_REPO_PREFIX}scans` + +/** + * Normalize then ensure the Vizably create-path prefix. + * Idempotent: names that already start with `viz_` (any casing) keep a single prefix. + * + * @param {unknown} name + * @returns {string} + */ +export function applyVizablyRepoPrefix(name) { + const normalized = normalizeGitHubRepoName(name) + if (!normalized) { + return '' + } + if (normalized.toLowerCase().startsWith(VIZABLY_REPO_PREFIX)) { + return `${VIZABLY_REPO_PREFIX}${normalized.slice(VIZABLY_REPO_PREFIX.length)}` + } + return `${VIZABLY_REPO_PREFIX}${normalized}` +} + +/** + * Next unused create-path name. Tries `viz_scans`, then `viz_scans-2`, … + * + * @param {Iterable} [takenNames] + * @returns {string} + */ +export function nextVizablyStoreName(takenNames = []) { + const taken = new Set( + [...takenNames].map((name) => String(name ?? '').toLowerCase()).filter(Boolean), + ) + if (!taken.has(VIZABLY_DEFAULT_STORE_NAME)) { + return VIZABLY_DEFAULT_STORE_NAME + } + for (let n = 2; n <= 999; n += 1) { + const candidate = `${VIZABLY_DEFAULT_STORE_NAME}-${n}` + if (!taken.has(candidate)) { + return candidate + } + } + throw new Error('Could not find an available Vizably repository name') +} diff --git a/frontend/src/views/ConnectView.jsx b/frontend/src/views/ConnectView.jsx index 1e74e5f..63a5e81 100644 --- a/frontend/src/views/ConnectView.jsx +++ b/frontend/src/views/ConnectView.jsx @@ -3,7 +3,7 @@ import { Button, Card } from '../design-system' import { Ico, GoogleMark } from '../lib/icons' import { apiClient } from '../lib/apiClient' import { PROVIDERS } from '../data/placeholders' -import { normalizeGitHubRepoName } from '../utils/githubRepoName' +import { VIZABLY_DEFAULT_STORE_NAME } from '../utils/githubRepoName' const STATUS_UI = { loadable: { @@ -62,98 +62,18 @@ function reasonMessage(reason) { } } -function storageRefFromRepo(repo) { +function storageRefFromHit(hit) { + const ref = hit.storageRef || hit return { - id: repo.id, - full_name: repo.full_name, - html_url: repo.html_url, + id: ref.id, + full_name: ref.full_name, + html_url: ref.html_url, + name: ref.name, } } -function findRepoByName(storages, name) { - const trimmed = name.trim() - if (!trimmed) return null - return ( - storages.find((r) => r.name === trimmed) || - storages.find((r) => r.full_name === trimmed) || - storages.find((r) => r.full_name.endsWith(`/${trimmed}`)) || - null - ) -} - -const NAME_CHECK_DEBOUNCE_MS = 400 - -/** - * Stable option card — must live outside ConnectView so typing into nested - * inputs does not remount this tree (and steal focus) on every keystroke. - */ -function ConnectOption({ active, onSelect, icon, title, desc, children }) { - return ( -
-
- - {active && ( - - )} - -
-
- - {Ico(icon, 17, 'currentColor')} - - - {title} - -
-

- {desc} -

- {active && children &&
{children}
} -
-
-
- ) -} - /** - * Connect storage — pick a GitHub repo, run fit-check, load or init account. + * Connect storage — discover or create the Vizably store, then load or init. * * @param {object} props * @param {'github' | 'google'} props.provider @@ -172,228 +92,129 @@ export default function ConnectView({ const pv = PROVIDERS[provider] || PROVIDERS.github const isGitHub = provider === 'github' - const [mode, setMode] = useState('existing') - const [newRepoName, setNewRepoName] = useState(pv.dest) - const [storages, setStorages] = useState([]) + const [stores, setStores] = useState([]) const [selectedId, setSelectedId] = useState('') - const [createdStorageRef, setCreatedStorageRef] = useState(null) + const [createdRef, setCreatedRef] = useState(null) + const [validation, setValidation] = useState(null) const [needsInstall, setNeedsInstall] = useState(false) const [installUrl, setInstallUrl] = useState(null) - const [validation, setValidation] = useState(null) - const [loadingRepos, setLoadingRepos] = useState(false) + const [discovering, setDiscovering] = useState(false) const [creating, setCreating] = useState(false) const [validating, setValidating] = useState(false) const [confirming, setConfirming] = useState(false) const [error, setError] = useState(storageError) - const [listError, setListError] = useState(null) - const [nameAvailability, setNameAvailability] = useState(null) - const [checkingName, setCheckingName] = useState(false) - const selectedRepo = useMemo( - () => storages.find((r) => r.id === selectedId) ?? null, - [storages, selectedId], + const selectedHit = useMemo( + () => stores.find((hit) => hit.storageRef.id === selectedId) ?? null, + [stores, selectedId], ) - const activeStorageRef = useMemo(() => { - if (!isGitHub) return null - if (mode === 'existing') { - return selectedRepo ? storageRefFromRepo(selectedRepo) : null - } - if (createdStorageRef) { - return { - id: createdStorageRef.id, - full_name: createdStorageRef.full_name, - html_url: createdStorageRef.html_url, + const activeStorageRef = createdRef || (selectedHit ? storageRefFromHit(selectedHit) : null) + const statusUi = validation ? STATUS_UI[validation.status] : null + const proposedAction = statusUi?.action ?? null + const canWrite = validation?.capabilities?.canWrite !== false + const initBlocked = proposedAction === 'init' && validation && !canWrite + const emptyAccount = stores.length === 0 && !createdRef + const ambiguous = stores.length > 1 && !createdRef + + const confirmBlocked = + needsInstall || + (!emptyAccount && + (!validation || + !proposedAction || + validation.status === 'incompatible' || + validation.status === 'invalid' || + initBlocked || + !activeStorageRef)) + + const confirmLabel = useMemo(() => { + if (emptyAccount) return 'Set up Vizably storage' + if (statusUi?.button) return statusUi.button + return 'Continue' + }, [emptyAccount, statusUi]) + + const runValidation = useCallback( + async (storageRef) => { + if (!storageRef) { + setValidation(null) + return } - } - const match = findRepoByName(storages, newRepoName) - return match ? storageRefFromRepo(match) : null - }, [isGitHub, mode, selectedRepo, storages, newRepoName, createdStorageRef]) + setValidating(true) + setError(null) + try { + const result = await client.validateStorage('github', storageRef) + setValidation(result) + if (result?.capabilities?.canWrite) { + setNeedsInstall(false) + } + } catch (err) { + setValidation(null) + setError(err.message || 'Failed to validate storage') + } finally { + setValidating(false) + } + }, + [client], + ) - const loadStorages = useCallback(async () => { - if (!isGitHub) return - setLoadingRepos(true) - setListError(null) + const loadDiscovery = useCallback(async () => { + if (!isGitHub) return [] + setDiscovering(true) + setError(null) try { - const result = await client.listStorages('github') - const list = result.storages ?? [] - setStorages(list) - setSelectedId((prev) => prev || list[0]?.id || '') + const result = await client.discoverStorages('github') + const list = result.stores ?? [] + setStores(list) + setSelectedId((prev) => { + if (prev && list.some((hit) => hit.storageRef.id === prev)) return prev + return list[0]?.storageRef.id || '' + }) return list } catch (err) { - setListError(err.message || 'Failed to load repositories') - setStorages([]) + setError(err.message || 'Failed to look up Vizably storage') + setStores([]) setSelectedId('') return [] } finally { - setLoadingRepos(false) + setDiscovering(false) } }, [client, isGitHub]) useEffect(() => { - loadStorages() - }, [loadStorages]) + loadDiscovery() + }, [loadDiscovery]) useEffect(() => { setError(storageError) }, [storageError]) useEffect(() => { - if (!isGitHub || mode !== 'new' || createdStorageRef) { - setNameAvailability(null) - setCheckingName(false) - return undefined - } - - const trimmed = newRepoName.trim() - if (!trimmed) { - setNameAvailability(null) - setCheckingName(false) - return undefined - } - - // Instant feedback when the loaded repo list already contains this name. - const localMatch = findRepoByName(storages, trimmed) - if (localMatch) { - setCheckingName(false) - setNameAvailability({ - name: trimmed, - normalizedName: localMatch.name, - full_name: localMatch.full_name, - status: 'taken', - message: `A repository named "${localMatch.name}" already exists on your account.`, - }) - return undefined - } - - let cancelled = false - setCheckingName(true) - const timer = setTimeout(async () => { - try { - const result = await client.checkRepoNameAvailability(trimmed) - if (!cancelled) { - setNameAvailability(result) - } - } catch (err) { - if (!cancelled) { - setNameAvailability({ - name: trimmed, - normalizedName: null, - full_name: null, - status: 'error', - message: err.message || 'Could not check repository name availability.', - }) - } - } finally { - if (!cancelled) { - setCheckingName(false) - } - } - }, NAME_CHECK_DEBOUNCE_MS) - - return () => { - cancelled = true - clearTimeout(timer) - } - }, [isGitHub, mode, newRepoName, client, createdStorageRef, storages]) - - const runValidation = useCallback(async (storageRef) => { - if (!storageRef) { + if (!isGitHub) return + if (needsInstall) { setValidation(null) return } - setValidating(true) - setError(null) - try { - const result = await client.validateStorage('github', storageRef) - setValidation(result) - if (result?.capabilities?.canWrite) { - setNeedsInstall(false) - } - } catch (err) { - setValidation(null) - setError(err.message || 'Failed to validate storage') - } finally { - setValidating(false) - } - }, [client]) - - useEffect(() => { - if (!isGitHub || !activeStorageRef) { - setValidation(null) + if (createdRef) { + runValidation(createdRef) return } - if (needsInstall && mode === 'new') { + if (selectedHit) { + setValidation(selectedHit.validation) + } else { setValidation(null) - return - } - runValidation(activeStorageRef) - }, [isGitHub, activeStorageRef, runValidation, needsInstall, mode]) - - const statusUi = validation ? STATUS_UI[validation.status] : null - const proposedAction = statusUi?.action ?? null - const canWrite = validation?.capabilities?.canWrite !== false - const initBlocked = proposedAction === 'init' && validation && !canWrite - const awaitingCreate = - mode === 'new' && !activeStorageRef && Boolean(newRepoName.trim()) - const nameUnavailable = - awaitingCreate && - (checkingName || - !nameAvailability || - nameAvailability.status === 'taken' || - nameAvailability.status === 'invalid') - const confirmBlocked = - !validation || - !proposedAction || - validation.status === 'incompatible' || - validation.status === 'invalid' || - initBlocked || - (mode === 'new' && needsInstall) || - (mode === 'new' && !activeStorageRef) - - const confirmLabel = useMemo(() => { - if (awaitingCreate) return 'Create repository' - if (statusUi?.button) return statusUi.button - return 'Continue' - }, [awaitingCreate, statusUi]) - - const primaryDisabled = - creating || - confirming || - validating || - (mode === 'new' && needsInstall) || - (awaitingCreate ? nameUnavailable : confirmBlocked) - - const handleCreateRepo = async () => { - const name = normalizeGitHubRepoName(newRepoName) - if (!name || creating || nameUnavailable) return - - // Reflect normalized name in the input so users see what will be created. - if (name !== newRepoName) { - setNewRepoName(name) } + }, [isGitHub, createdRef, selectedHit, needsInstall, runValidation]) + const handleCreateDefault = async () => { + if (creating) return setCreating(true) setError(null) setNeedsInstall(false) setInstallUrl(null) try { - const result = await client.createStorage(name) - const ref = result.storageRef - const asListItem = { - id: ref.id, - name: ref.name || ref.full_name?.split('/')[1], - full_name: ref.full_name, - private: ref.private ?? true, - html_url: ref.html_url, - } - setCreatedStorageRef(asListItem) - setStorages((prev) => { - if (prev.some((r) => r.id === asListItem.id)) return prev - return [asListItem, ...prev] - }) - setSelectedId(asListItem.id) - + const result = await client.createStorage() + const ref = storageRefFromHit({ storageRef: result.storageRef }) + setCreatedRef(ref) if (result.needsInstall) { setNeedsInstall(true) setInstallUrl(result.installUrl) @@ -401,34 +222,24 @@ export default function ConnectView({ } else { setNeedsInstall(false) setInstallUrl(null) - await runValidation({ - id: asListItem.id, - full_name: asListItem.full_name, - html_url: asListItem.html_url, - }) + setConfirming(true) + try { + await client.setupStorage('github', ref, 'init') + onDone() + } catch (initErr) { + setError(initErr.message || 'Failed to connect storage') + await runValidation(ref) + } finally { + setConfirming(false) + } } } catch (err) { - // Probe failures (rate limit / network) may still return storageRef — keep - // the created repo selected without the "install App" CTA. if (err.storageRef) { - const ref = err.storageRef - const asListItem = { - id: ref.id, - name: ref.name || ref.full_name?.split('/')[1], - full_name: ref.full_name, - private: ref.private ?? true, - html_url: ref.html_url, - } - setCreatedStorageRef(asListItem) - setStorages((prev) => { - if (prev.some((r) => r.id === asListItem.id)) return prev - return [asListItem, ...prev] - }) - setSelectedId(asListItem.id) + setCreatedRef(storageRefFromHit({ storageRef: err.storageRef })) setNeedsInstall(false) setInstallUrl(null) } - setError(err.message || 'Failed to create repository') + setError(err.message || 'Failed to create storage') } finally { setCreating(false) } @@ -436,35 +247,27 @@ export default function ConnectView({ const handleRefreshAfterInstall = async () => { setError(null) - const list = await loadStorages() + const list = await loadDiscovery() const match = - (createdStorageRef && list.find((r) => r.id === createdStorageRef.id)) || - findRepoByName(list, newRepoName) || - createdStorageRef + (createdRef && list.find((hit) => hit.storageRef.id === createdRef.id)) || + createdRef if (!match) { setError( 'Repository not found yet. Add it to the Vizably GitHub App installation, then refresh again.', ) return } - const ref = storageRefFromRepo(match) - setCreatedStorageRef({ - id: match.id, - name: match.name, - full_name: match.full_name, - private: match.private, - html_url: match.html_url, - }) + const ref = storageRefFromHit(match.storageRef ? match : { storageRef: match }) + setCreatedRef(ref) setNeedsInstall(false) await runValidation(ref) } const handleConfirm = async () => { - if (mode === 'new' && !activeStorageRef && newRepoName.trim()) { - await handleCreateRepo() + if (emptyAccount) { + await handleCreateDefault() return } - if (confirmBlocked || !activeStorageRef || !proposedAction) return setConfirming(true) @@ -481,69 +284,6 @@ export default function ConnectView({ const providerIcon = provider === 'google' ? GoogleMark(20) : Ico('Github', 20) - const nameCheckUi = (() => { - if (checkingName) { - return { - tone: 'checking', - label: 'Checking', - icon: 'Loader2', - color: 'var(--text-muted)', - bg: 'var(--bg-inset)', - border: 'var(--border-default)', - inputBorder: 'var(--border-strong)', - message: 'Checking availability…', - } - } - switch (nameAvailability?.status) { - case 'available': - return { - tone: 'available', - label: 'Available', - icon: 'CircleCheck', - color: 'var(--green-700)', - bg: 'var(--green-50)', - border: 'var(--green-100)', - inputBorder: 'var(--green-600)', - message: nameAvailability.message, - } - case 'taken': - return { - tone: 'taken', - label: 'Taken', - icon: 'CircleX', - color: 'var(--sev-serious-fg)', - bg: 'var(--sev-serious-bg)', - border: 'var(--sev-serious)', - inputBorder: 'var(--sev-serious)', - message: nameAvailability.message, - } - case 'invalid': - return { - tone: 'invalid', - label: 'Invalid', - icon: 'TriangleAlert', - color: 'var(--sev-serious-fg)', - bg: 'var(--sev-serious-bg)', - border: 'var(--sev-serious)', - inputBorder: 'var(--sev-serious)', - message: nameAvailability.message, - } - case 'error': - return { - tone: 'error', - label: 'Retry later', - icon: 'TriangleAlert', - color: 'var(--text-body)', - bg: 'var(--sev-moderate-bg)', - border: 'var(--sev-moderate)', - inputBorder: 'var(--sev-moderate)', - message: nameAvailability.message, - } - default: - return null - } - })() - if (!isGitHub) { return (
{Ico('Check', 15, 'currentColor')}

- Where should we save your scans? + Connect your Vizably storage

- Vizably writes each report to {pv.article} {pv.unit} in your {pv.name} — you stay in - control of it. + Vizably finds or creates a private {pv.unit} for your scans. You stay in control of it.

- {(error || listError) && ( + {error && (
- {error || listError} + {error}
)} -
- setMode('new')} - icon="Plus" - title={`Create a new ${pv.unit}`} - desc={`Create a fresh private ${pv.unitShort} under your GitHub account and set it up for Vizably.`} - > - -
- {Ico(pv.destIcon, 16)} - e.stopPropagation()} - onChange={(e) => { - const next = e.target.value - setNewRepoName(next) - setNameAvailability(null) - // Only clear create/install state when the typed name no longer - // matches the repo we just created — avoids extra re-render churn. - if ( - createdStorageRef && - next.trim() !== createdStorageRef.name && - next.trim() !== createdStorageRef.full_name - ) { - setCreatedStorageRef(null) - setNeedsInstall(false) - setInstallUrl(null) - } - }} - disabled={creating} - aria-describedby="repo-name-availability" + {discovering ? ( +

+ Looking for Vizably storage… +

+ ) : emptyAccount ? ( +

+ No Vizably account store yet. We'll create{' '} + {VIZABLY_DEFAULT_STORE_NAME} + {' '}(or the next free name) as a private repository under your GitHub account. +

+ ) : ambiguous ? ( +
+

+ More than one Vizably store was found. Pick which one to use on this device. +

+
+ + e.stopPropagation()} > -

- Repository created - {createdStorageRef?.full_name ? ( - <> - {' '} - ({createdStorageRef.full_name}) - - ) : null} - . Add it to your Vizably GitHub App installation, then continue. -

-
- {installUrl && ( - - Open GitHub App install - - )} - -
-
- )} - + {Ico('ChevronDown', 16)} + +
+
+ ) : ( +

+ Using{' '} + + {activeStorageRef?.full_name || VIZABLY_DEFAULT_STORE_NAME} + + . +

+ )} - setMode('existing')} - icon="FolderOpen" - title={`Use an existing ${pv.unit}`} - desc={`Pick ${pv.article} ${pv.unit} from your GitHub account.`} + {needsInstall && ( +
- {loadingRepos ? ( -

- Loading repositories… -

- ) : storages.length === 0 ? ( -

- No repositories found. Create one on GitHub or check app installation. -

- ) : ( -
- - + Repository created + {createdRef?.full_name ? ( + <> + {' '} + ({createdRef.full_name}) + + ) : null} + . Add it to your Vizably GitHub App installation, then continue. +

+ - )} - - -
+ Open GitHub App install + + )} + +
+
+ )}
{validating && ( @@ -930,7 +501,7 @@ export default function ConnectView({

)} - {validation && statusUi && !validating && ( + {validation && statusUi && !validating && !needsInstall && (
{statusUi.detail(validation)}

- {validation.reason === 'repairable' && ( -

- The scan index will be rebuilt when you load this account. -

- )} {initBlocked && (

This storage is read-only — you can load saved scans but cannot set up or save new ones. @@ -1002,7 +568,13 @@ export default function ConnectView({ variant="primary" size="lg" style={{ flex: 1 }} - disabled={primaryDisabled} + disabled={ + discovering || + creating || + confirming || + validating || + (emptyAccount ? false : confirmBlocked) + } onClick={handleConfirm} iconRight={Ico('ArrowRight', 17, '#fff')} > diff --git a/shared/githubRepoName.js b/shared/githubRepoName.js index a411cea..b6758ac 100644 --- a/shared/githubRepoName.js +++ b/shared/githubRepoName.js @@ -20,4 +20,56 @@ function normalizeGitHubRepoName(name) { .replace(/^-+|-+$/g, ''); } -module.exports = { normalizeGitHubRepoName }; +/** Prefix applied to every repository Vizably creates. */ +const VIZABLY_REPO_PREFIX = 'viz_'; + +/** First-choice store name for a new account (`viz_scans`, then `viz_scans-2`, …). */ +const VIZABLY_DEFAULT_STORE_NAME = `${VIZABLY_REPO_PREFIX}scans`; + +/** + * Normalize then ensure the Vizably create-path prefix. + * Idempotent: names that already start with `viz_` (any casing) keep a single prefix. + * + * @param {unknown} name + * @returns {string} + */ +function applyVizablyRepoPrefix(name) { + const normalized = normalizeGitHubRepoName(name); + if (!normalized) { + return ''; + } + if (normalized.toLowerCase().startsWith(VIZABLY_REPO_PREFIX)) { + return `${VIZABLY_REPO_PREFIX}${normalized.slice(VIZABLY_REPO_PREFIX.length)}`; + } + return `${VIZABLY_REPO_PREFIX}${normalized}`; +} + +/** + * Next unused create-path name. Tries `viz_scans`, then `viz_scans-2`, … + * + * @param {Iterable} [takenNames] + * @returns {string} + */ +function nextVizablyStoreName(takenNames = []) { + const taken = new Set( + [...takenNames].map((name) => String(name ?? '').toLowerCase()).filter(Boolean), + ); + if (!taken.has(VIZABLY_DEFAULT_STORE_NAME)) { + return VIZABLY_DEFAULT_STORE_NAME; + } + for (let n = 2; n <= 999; n += 1) { + const candidate = `${VIZABLY_DEFAULT_STORE_NAME}-${n}`; + if (!taken.has(candidate)) { + return candidate; + } + } + throw new Error('Could not find an available Vizably repository name'); +} + +module.exports = { + VIZABLY_REPO_PREFIX, + VIZABLY_DEFAULT_STORE_NAME, + normalizeGitHubRepoName, + applyVizablyRepoPrefix, + nextVizablyStoreName, +};