From b09f2235942d01f4a2a5a4471786a100dd1f87b4 Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Wed, 27 May 2026 09:21:42 -0400 Subject: [PATCH 01/31] Add login.gov auth scaffold and document rollout pause --- packages/apollo-cli/README.md | 2 +- packages/apollo-cli/src/ApolloConf.ts | 10 +- .../apollo-collaboration-server/package.json | 1 + .../src/app.module.ts | 11 ++ .../authentication.controller.ts | 10 +- .../authentication/authentication.module.ts | 2 + .../authentication/authentication.service.ts | 30 +++++ .../src/declare.d.ts | 32 +++++ .../src/utils/logingov.guard.ts | 19 +++ .../utils/strategies/login-gov.strategy.ts | 115 ++++++++++++++++++ .../components/AuthTypeSelector.tsx | 24 +++- .../components/LoginButtons.tsx | 14 +++ .../src/ApolloInternetAccount/model.ts | 2 +- .../02-installation/03-login-management.md | 9 +- .../04-configuration-options.md | 24 +++- .../website/docs/03-guides/04-cli/config.md | 2 +- yarn.lock | 18 +++ 17 files changed, 311 insertions(+), 14 deletions(-) create mode 100644 packages/apollo-collaboration-server/src/utils/logingov.guard.ts create mode 100644 packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts diff --git a/packages/apollo-cli/README.md b/packages/apollo-cli/README.md index 460cf0685..5ac99faa7 100644 --- a/packages/apollo-cli/README.md +++ b/packages/apollo-cli/README.md @@ -335,7 +335,7 @@ DESCRIPTION Address and port e.g http://localhost:3999 - accessType: - How to access Apollo. accessType is typically one of: google, microsoft, guest, root. Allowed types depend on your + How to access Apollo. accessType is typically one of: google, microsoft, logingov, guest, root. Allowed types depend on your Apollo setup - accessToken: diff --git a/packages/apollo-cli/src/ApolloConf.ts b/packages/apollo-cli/src/ApolloConf.ts index af106cd85..91b66c165 100644 --- a/packages/apollo-cli/src/ApolloConf.ts +++ b/packages/apollo-cli/src/ApolloConf.ts @@ -32,7 +32,7 @@ function optionDocs(): { key: string; description: string }[] { docs.push({ key: v, description: - 'How to access Apollo. accessType is typically one of: google, microsoft, guest, root. Allowed types depend on your Apollo setup', + 'How to access Apollo. accessType is typically one of: google, microsoft, logingov, guest, root. Allowed types depend on your Apollo setup', }) break } @@ -147,7 +147,13 @@ export class ApolloConf extends Conf { const profileSchema = Joi.object({ address: Joi.string().uri({ scheme: /https?/ }), - accessType: Joi.string().valid('google', 'microsoft', 'root', 'guest'), + accessType: Joi.string().valid( + 'google', + 'microsoft', + 'logingov', + 'root', + 'guest', + ), accessToken: Joi.string(), rootPassword: Joi.string().when('accessType', { is: Joi.string().valid('root'), diff --git a/packages/apollo-collaboration-server/package.json b/packages/apollo-collaboration-server/package.json index f3ab1ca6f..fec318750 100644 --- a/packages/apollo-collaboration-server/package.json +++ b/packages/apollo-collaboration-server/package.json @@ -78,6 +78,7 @@ "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", "passport-microsoft": "^1.0.0", + "passport-openidconnect": "^0.1.2", "prop-types": "^15.8.1", "quick-lru": "^7.3.0", "react": "^18.2.0", diff --git a/packages/apollo-collaboration-server/src/app.module.ts b/packages/apollo-collaboration-server/src/app.module.ts index 132c05664..2c252ccb5 100644 --- a/packages/apollo-collaboration-server/src/app.module.ts +++ b/packages/apollo-collaboration-server/src/app.module.ts @@ -51,6 +51,15 @@ const validationSchema = Joi.object({ MICROSOFT_CLIENT_ID_FILE: Joi.string(), MICROSOFT_CLIENT_SECRET: Joi.string(), MICROSOFT_CLIENT_SECRET_FILE: Joi.string(), + LOGINGOV_CLIENT_ID: Joi.string(), + LOGINGOV_CLIENT_ID_FILE: Joi.string(), + LOGINGOV_CLIENT_SECRET: Joi.string(), + LOGINGOV_CLIENT_SECRET_FILE: Joi.string(), + LOGINGOV_ISSUER_BASE_URL: Joi.string().uri(), + LOGINGOV_AUTHORIZATION_URL: Joi.string().uri(), + LOGINGOV_TOKEN_URL: Joi.string().uri(), + LOGINGOV_USER_INFO_URL: Joi.string().uri(), + LOGINGOV_SCOPE: Joi.string(), JWT_SECRET: Joi.string(), JWT_SECRET_FILE: Joi.string(), SESSION_SECRET: Joi.string(), @@ -119,6 +128,8 @@ const validationSchema = Joi.object({ .oxor('GOOGLE_CLIENT_SECRET', 'GOOGLE_CLIENT_SECRET_FILE') .oxor('MICROSOFT_CLIENT_ID', 'MICROSOFT_CLIENT_ID_FILE') .oxor('MICROSOFT_CLIENT_SECRET', 'MICROSOFT_CLIENT_SECRET_FILE') + .oxor('LOGINGOV_CLIENT_ID', 'LOGINGOV_CLIENT_ID_FILE') + .oxor('LOGINGOV_CLIENT_SECRET', 'LOGINGOV_CLIENT_SECRET_FILE') .xor('JWT_SECRET', 'JWT_SECRET_FILE') .xor('SESSION_SECRET', 'SESSION_SECRET_FILE') .xor('PLUGIN_URLS', 'PLUGIN_URLS_FILE') diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.controller.ts b/packages/apollo-collaboration-server/src/authentication/authentication.controller.ts index a73a6e608..5d065ad5b 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.controller.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.controller.ts @@ -21,6 +21,7 @@ import { AuthenticationService, type RequestWithUserToken, } from './authentication.service.js' +import { LoginGovAuthGuard } from '../utils/logingov.guard.js' @Validations(Role.None) @Controller('auth') @@ -44,7 +45,7 @@ export class AuthenticationController { if (redirect_uri) { params.set('redirect_uri', redirect_uri) } - if (['google', 'microsoft', 'guest'].includes(type)) { + if (['google', 'microsoft', 'logingov', 'guest'].includes(type)) { const url = redirect_uri ? `${type}?${new URLSearchParams({ redirect_uri }).toString()}` : type @@ -67,6 +68,13 @@ export class AuthenticationController { return this.authService.handleRedirect(req) } + @Get('logingov') + @Redirect() + @UseGuards(LoginGovAuthGuard) + async loginGovHandleRedirect(@Req() req: RequestWithUserToken) { + return this.authService.handleRedirect(req) + } + @Get('guest') guestLogin() { return this.authService.guestLogin() diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.module.ts b/packages/apollo-collaboration-server/src/authentication/authentication.module.ts index 7fa66c985..15f658aaf 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.module.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.module.ts @@ -8,6 +8,7 @@ import { PassportModule } from '@nestjs/passport' import { UsersModule } from '../users/users.module.js' import { GoogleStrategy } from '../utils/strategies/google.strategy.js' import { JwtStrategy } from '../utils/strategies/jwt.strategy.js' +import { LoginGovStrategy } from '../utils/strategies/login-gov.strategy.js' import { MicrosoftStrategy } from '../utils/strategies/microsoft.strategy.js' import { AuthenticationController } from './authentication.controller.js' @@ -47,6 +48,7 @@ async function jwtConfigFactory( AuthenticationService, JwtStrategy, GoogleStrategy, + LoginGovStrategy, MicrosoftStrategy, ], exports: [AuthenticationService], diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.service.ts b/packages/apollo-collaboration-server/src/authentication/authentication.service.ts index e51952e50..1664ec956 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.service.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.service.ts @@ -33,6 +33,8 @@ interface ConfigValues { MICROSOFT_CLIENT_ID_FILE?: string GOOGLE_CLIENT_ID?: string GOOGLE_CLIENT_ID_FILE?: string + LOGINGOV_CLIENT_ID?: string + LOGINGOV_CLIENT_ID_FILE?: string ALLOW_GUEST_USER: boolean DEFAULT_NEW_USER_ROLE: Role ROOT_USER_PASSWORD: string @@ -92,6 +94,16 @@ export class AuthenticationService { googleClientID = clientIDFile && (await fs.readFile(clientIDFile, 'utf8')) googleClientID = clientIDFile?.trim() } + let loginGovClientID = this.configService.get('LOGINGOV_CLIENT_ID', { + infer: true, + }) + if (!loginGovClientID) { + const clientIDFile = this.configService.get('LOGINGOV_CLIENT_ID_FILE', { + infer: true, + }) + loginGovClientID = + clientIDFile && (await fs.readFile(clientIDFile, 'utf8')).trim() + } const allowGuestUser = this.configService.get('ALLOW_GUEST_USER', { infer: true, }) @@ -101,6 +113,9 @@ export class AuthenticationService { if (googleClientID) { loginTypes.push('google') } + if (loginGovClientID) { + loginTypes.push('logingov') + } if (allowGuestUser) { loginTypes.push('guest') } @@ -134,6 +149,21 @@ export class AuthenticationService { return this.logIn(displayName, email.value) } + async loginGovLogin(profile: { + id: string + displayName?: string + emails?: { value: string }[] + _json?: { email?: string; sub?: string } + }) { + const email = profile.emails?.[0]?.value ?? profile._json?.email + if (!email) { + throw new UnauthorizedException('No email provided') + } + const displayName = + profile.displayName ?? profile._json?.sub ?? 'login.gov user' + return this.logIn(displayName, email) + } + /** * Log in as a guest * @returns Return either token with HttpResponse status 'HttpStatus.OK' OR null with 'HttpStatus.UNAUTHORIZED' diff --git a/packages/apollo-collaboration-server/src/declare.d.ts b/packages/apollo-collaboration-server/src/declare.d.ts index 5deb37ceb..c410c063a 100644 --- a/packages/apollo-collaboration-server/src/declare.d.ts +++ b/packages/apollo-collaboration-server/src/declare.d.ts @@ -89,3 +89,35 @@ declare module 'connect-mongodb-session' { export default connectMongoDBSession } + +declare module 'passport-openidconnect' { + import type { Strategy as PassportStrategyBase } from 'passport' + + interface Profile { + id: string + displayName?: string + emails?: { value: string }[] + _json?: Record + } + + interface StrategyOptions { + issuer: string + authorizationURL: string + tokenURL: string + userInfoURL: string + clientID: string + clientSecret: string + callbackURL?: string + scope?: string + state?: boolean + } + + class Strategy extends PassportStrategyBase { + constructor( + options: StrategyOptions, + verify: (issuer: string, profile: Profile, done: Function) => void, + ) + } + + export { Profile, Strategy, StrategyOptions } +} diff --git a/packages/apollo-collaboration-server/src/utils/logingov.guard.ts b/packages/apollo-collaboration-server/src/utils/logingov.guard.ts new file mode 100644 index 000000000..4dba38a90 --- /dev/null +++ b/packages/apollo-collaboration-server/src/utils/logingov.guard.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { type ExecutionContext, Injectable } from '@nestjs/common' +import { AuthGuard, type IAuthModuleOptions } from '@nestjs/passport' + +@Injectable() +export class LoginGovAuthGuard extends AuthGuard('logingov') { + getAuthenticateOptions( + context: ExecutionContext, + ): IAuthModuleOptions | undefined { + const [req] = context.getArgs() + const { query } = req + const redirectUri = query.redirect_uri + if (redirectUri) { + return { state: { redirect_uri: redirectUri } } + } + return + } +} diff --git a/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts b/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts new file mode 100644 index 000000000..66bc97c65 --- /dev/null +++ b/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts @@ -0,0 +1,115 @@ +import fs from 'node:fs' + +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { PassportStrategy } from '@nestjs/passport' +import { HttpsProxyAgent } from 'https-proxy-agent' +import { Strategy } from 'passport-openidconnect' + +import { AuthenticationService } from '../../authentication/authentication.service.js' + +interface ConfigValues { + LOGINGOV_CLIENT_ID?: string + LOGINGOV_CLIENT_ID_FILE?: string + LOGINGOV_CLIENT_SECRET?: string + LOGINGOV_CLIENT_SECRET_FILE?: string + LOGINGOV_ISSUER_BASE_URL?: string + LOGINGOV_AUTHORIZATION_URL?: string + LOGINGOV_TOKEN_URL?: string + LOGINGOV_USER_INFO_URL?: string + LOGINGOV_SCOPE?: string + URL: string + OAUTH_HTTP_PROXY?: string +} + +interface LoginGovProfile { + id: string + displayName?: string + emails?: { value: string }[] + _json?: { email?: string; sub?: string } +} + +@Injectable() +export class LoginGovStrategy extends PassportStrategy(Strategy, 'logingov') { + private readonly logger = new Logger(LoginGovStrategy.name) + + constructor( + private readonly authService: AuthenticationService, + configService: ConfigService, + ) { + let clientID = configService.get('LOGINGOV_CLIENT_ID', { infer: true }) + if (!clientID) { + const clientIDFile = configService.get('LOGINGOV_CLIENT_ID_FILE', { + infer: true, + }) + clientID = clientIDFile && fs.readFileSync(clientIDFile, 'utf8').trim() + } + const configured = Boolean(clientID) + if (!configured) { + clientID = 'none' + } + + let clientSecret = 'none' + let callbackURL + const issuerBaseURL = + configService.get('LOGINGOV_ISSUER_BASE_URL', { infer: true }) ?? + 'https://secure.login.gov' + const authorizationURL = + configService.get('LOGINGOV_AUTHORIZATION_URL', { infer: true }) ?? + `${issuerBaseURL}/openid_connect/authorize` + const tokenURL = + configService.get('LOGINGOV_TOKEN_URL', { infer: true }) ?? + `${issuerBaseURL}/api/openid_connect/token` + const userInfoURL = + configService.get('LOGINGOV_USER_INFO_URL', { infer: true }) ?? + `${issuerBaseURL}/api/openid_connect/userinfo` + const scope = + configService.get('LOGINGOV_SCOPE', { infer: true }) ?? + 'openid email profile' + + if (configured) { + clientSecret = configService.get('LOGINGOV_CLIENT_SECRET', { + infer: true, + }) + if (!clientSecret) { + const clientSecretFile = configService.get( + 'LOGINGOV_CLIENT_SECRET_FILE', + { infer: true }, + )! + clientSecret = fs.readFileSync(clientSecretFile, 'utf8').trim() + } + const urlString = configService.get('URL', { infer: true }) + const callbackURI = new URL(urlString) + callbackURI.pathname = `${callbackURI.pathname}${ + callbackURI.pathname.endsWith('/') ? '' : '/' + }auth/logingov` + callbackURL = callbackURI.href + } + + super({ + issuer: issuerBaseURL, + authorizationURL, + tokenURL, + userInfoURL, + clientID, + clientSecret, + callbackURL, + scope, + state: true, + }) + + const proxy = configService.get('OAUTH_HTTP_PROXY', { infer: true }) + if (proxy) { + const agent = new HttpsProxyAgent(proxy) + const oauth2 = (this as any)._oauth2 + if (oauth2) { + oauth2.setAgent(agent) + this.logger.debug(`LoginGovStrategy configured to use proxy: ${proxy}`) + } + } + } + + async validate(issuer: string, profile: LoginGovProfile) { + return this.authService.loginGovLogin(profile) + } +} diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx index 613eeb586..f39fe6303 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx @@ -13,7 +13,12 @@ import React, { useEffect, useState } from 'react' import { Dialog } from '../../components/Dialog' import { createFetchErrorMessage } from '../../util' -import { GoogleButton, GuestButton, MicrosoftButton } from './LoginButtons' +import { + GoogleButton, + GuestButton, + LoginGovButton, + MicrosoftButton, +} from './LoginButtons' const useStyles = makeStyles()((theme) => ({ divider: { @@ -29,7 +34,9 @@ export const AuthTypeSelector = ({ }: { baseURL: string name: string - handleClose: (type?: 'google' | 'microsoft' | 'guest' | Error) => void + handleClose: ( + type?: 'google' | 'microsoft' | 'logingov' | 'guest' | Error, + ) => void }) => { const { classes } = useStyles() const [errorMessage, setErrorMessage] = useState('') @@ -66,11 +73,13 @@ export const AuthTypeSelector = ({ } }, [baseURL]) - function handleClick(authType: 'google' | 'microsoft' | 'guest') { + function handleClick(authType: 'google' | 'microsoft' | 'logingov' | 'guest') { if (authType === 'google') { handleClose('google') } else if (authType === 'microsoft') { handleClose('microsoft') + } else if (authType === 'logingov') { + handleClose('logingov') } else { handleClose('guest') } @@ -78,6 +87,7 @@ export const AuthTypeSelector = ({ const allowGoogle = loginTypes.includes('google') const allowMicrosoft = loginTypes.includes('microsoft') + const allowLoginGov = loginTypes.includes('logingov') const allowGuest = loginTypes.includes('guest') return ( ) : null} + {allowLoginGov ? ( + { + handleClick('logingov') + }} + /> + ) : null} {allowGuest ? ( <> diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/LoginButtons.tsx b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/LoginButtons.tsx index 752f64b4d..77eb12641 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/LoginButtons.tsx +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/LoginButtons.tsx @@ -47,6 +47,20 @@ export function MicrosoftButton(props: ButtonProps) { ) } +export function LoginGovButton(props: ButtonProps) { + const { classes } = useStyles() + return ( + + ) +} + export function GuestButton(props: ButtonProps) { const { classes } = useStyles() return ( diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts index 2909a8c43..8ee1025e2 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts @@ -41,7 +41,7 @@ import { createFetchErrorMessage } from '../util' import { AuthTypeSelector } from './components/AuthTypeSelector' import type { ApolloInternetAccountConfigModel } from './configSchema' -type AuthType = 'google' | 'microsoft' | 'guest' +type AuthType = 'google' | 'microsoft' | 'logingov' | 'guest' type Role = 'admin' | 'user' | 'readOnly' | 'none' diff --git a/packages/website/docs/02-installation/03-login-management.md b/packages/website/docs/02-installation/03-login-management.md index ba93c129c..71dd74c57 100644 --- a/packages/website/docs/02-installation/03-login-management.md +++ b/packages/website/docs/02-installation/03-login-management.md @@ -2,12 +2,19 @@ Apollo itself does not handle user logins. This lets us keep Apollo simpler and more secure, since it doesn't store passwords, but means you'll have to set up -third-party logins through Google or Microsoft. +third-party logins through Google, Microsoft, or Login.gov. Apollo also allows a single admin-level access root user with a password, usually for use with the CLI, and a passwordless guest user with configurable access level. +## Login.gov status + +Login.gov support is scaffolded in Apollo3, but rollout should be treated as +pending until your final callback endpoint is registered/allowlisted with +Login.gov. Keep the provider disabled in production until endpoint approval is +complete. + In order to set up these logins, you'll need Apollo to be hosted at a domain name that you own (e.g. the "Public IPv4 DNS" of an AWS EC2 instance will not work). diff --git a/packages/website/docs/02-installation/04-configuration-options.md b/packages/website/docs/02-installation/04-configuration-options.md index 3b6a4d0a9..90c46d66a 100644 --- a/packages/website/docs/02-installation/04-configuration-options.md +++ b/packages/website/docs/02-installation/04-configuration-options.md @@ -29,10 +29,10 @@ SESSION_SECRET=g9fGaRuw06T7hs960Tm7KYyfcFaYEIaG9jfFnVEQ4QyFXmq7 # Alternatively, can be a path to a file with the session secret # SESSION_SECRET_FILE=/run/secrets/session-secret -############################################################################## -## To enable users to log in, you need either (or both) Google or Microsoft ## -## OAuth configured. Without them, only userless guest access is possible. ## -############################################################################## +#################################################################################### +## To enable users to log in, configure one or more providers: Google, Microsoft, ## +## or Login.gov OAuth/OIDC. Without them, only userless guest access is possible. ## +#################################################################################### # Google client id and secret. GOOGLE_CLIENT_ID=client_id_here @@ -50,6 +50,22 @@ MICROSOFT_CLIENT_SECRET=client_secret_here # Alternatively, can be a path to a file with the client secret # MICROSOFT_CLIENT_SECRET_FILE=/run/secrets/microsoft-client-secret +# Login.gov client id and secret. +# Endpoints default to the Login.gov production issuer when omitted. +# You must register/allowlist your exact callback endpoint before enabling this. +LOGINGOV_CLIENT_ID=client_id_here +# Alternatively, can be a path to a file with the client ID +# LOGINGOV_CLIENT_ID_FILE=/run/secrets/logingov-client-id +LOGINGOV_CLIENT_SECRET=client_secret_here +# Alternatively, can be a path to a file with the client secret +# LOGINGOV_CLIENT_SECRET_FILE=/run/secrets/logingov-client-secret +# Optional overrides for OIDC endpoints and scope +# LOGINGOV_ISSUER_BASE_URL=https://secure.login.gov +# LOGINGOV_AUTHORIZATION_URL=https://secure.login.gov/openid_connect/authorize +# LOGINGOV_TOKEN_URL=https://secure.login.gov/api/openid_connect/token +# LOGINGOV_USER_INFO_URL=https://secure.login.gov/api/openid_connect/userinfo +# LOGINGOV_SCOPE=openid email profile + ############## ## OPTIONAL ## ############## diff --git a/packages/website/docs/03-guides/04-cli/config.md b/packages/website/docs/03-guides/04-cli/config.md index 08f699992..56300b586 100644 --- a/packages/website/docs/03-guides/04-cli/config.md +++ b/packages/website/docs/03-guides/04-cli/config.md @@ -30,7 +30,7 @@ DESCRIPTION Address and port e.g http://localhost:3999 - accessType: - How to access Apollo. accessType is typically one of: google, microsoft, guest, root. Allowed types depend on your + How to access Apollo. accessType is typically one of: google, microsoft, logingov, guest, root. Allowed types depend on your Apollo setup - accessToken: diff --git a/yarn.lock b/yarn.lock index 2376f1a5c..f2aa2c18a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -423,6 +423,7 @@ __metadata: passport-jwt: "npm:^4.0.0" passport-local: "npm:^1.0.0" passport-microsoft: "npm:^1.0.0" + passport-openidconnect: "npm:^0.1.2" prop-types: "npm:^15.8.1" quick-lru: "npm:^7.3.0" react: "npm:^18.2.0" @@ -26770,6 +26771,13 @@ __metadata: languageName: node linkType: hard +"oauth@npm:0.10.x": + version: 0.10.2 + resolution: "oauth@npm:0.10.2" + checksum: 10c0/5660d652b31eb2a90509989955a75b02311591d2e3cfb04a3fb91deae0d22c259aa5c31a9e8845f760d749af901032e3cfd06d151bbda9a5ebb9b76268d3aef9 + languageName: node + linkType: hard + "oauth@npm:0.9.x": version: 0.9.15 resolution: "oauth@npm:0.9.15" @@ -27557,6 +27565,16 @@ __metadata: languageName: node linkType: hard +"passport-openidconnect@npm:^0.1.2": + version: 0.1.2 + resolution: "passport-openidconnect@npm:0.1.2" + dependencies: + oauth: "npm:0.10.x" + passport-strategy: "npm:1.x.x" + checksum: 10c0/4d4662b91e2580baa7cf2f341904c352de9ec776a3897f8bf51a90f248ee7dab26b94e2e3c590d14821b04b0fa95cb8d7cfad6edc095e3966b7666e8779b0b68 + languageName: node + linkType: hard + "passport-strategy@npm:1.x.x, passport-strategy@npm:^1.0.0": version: 1.0.0 resolution: "passport-strategy@npm:1.0.0" From 5e344f5f305639e5b4237c74a67ab8b48f8318bc Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Wed, 27 May 2026 11:28:16 -0400 Subject: [PATCH 02/31] Fix guest JBrowse config handling Ensure authenticated guest requests receive assemblies and tracks. Assisted-by: GitHub Copilot (GPT-5.3-Codex) --- .../src/jbrowse/jbrowse.service.spec.ts | 63 ++++++++++++++++--- .../src/jbrowse/jbrowse.service.ts | 2 +- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.spec.ts b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.spec.ts index 71eed0411..02de0dd84 100644 --- a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.spec.ts +++ b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.spec.ts @@ -1,19 +1,62 @@ -import { Test, type TestingModule } from '@nestjs/testing' - import { JBrowseService } from './jbrowse.service.js' +import { Role } from '../utils/role/role.enum.js' describe('JBrowseService', () => { - let service: JBrowseService + function makeService() { + return new JBrowseService( + {} as never, + {} as never, + {} as never, + {} as never, + ) + } + + it('should be defined', () => { + const service = makeService() + expect(service).toBeDefined() + }) - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [JBrowseService], - }).compile() + it('returns a minimal config for unauthenticated requests', async () => { + const service = makeService() + const configuration = { theme: {} } + const plugins = [{ name: 'Apollo', url: 'apollo.js' }] + const internetAccounts = [{ type: 'ApolloInternetAccount' }] - service = module.get(JBrowseService) + jest.spyOn(service, 'getConfiguration').mockReturnValue(configuration) + jest.spyOn(service, 'getPlugins').mockReturnValue(plugins) + jest.spyOn(service, 'getInternetAccounts').mockReturnValue(internetAccounts) + + await expect(service.getConfig()).resolves.toEqual({ + configuration, + plugins, + internetAccounts, + }) }) - it('should be defined', () => { - expect(service).toBeDefined() + it('includes assemblies and tracks for guest-authenticated requests', async () => { + const service = makeService() + const configuration = { theme: {}, ApolloPlugin: { hasRole: true } } + const assemblies = [{ name: 'assembly-1' }] + const tracks = [{ trackId: 'track-1' }] + const plugins = [{ name: 'Apollo', url: 'apollo.js' }] + const internetAccounts = [{ type: 'ApolloInternetAccount' }] + const defaultSession = { name: 'Apollo', views: [] } + + jest.spyOn(service, 'getConfiguration').mockReturnValue(configuration) + jest.spyOn(service, 'getAssemblies').mockResolvedValue(assemblies as never) + jest.spyOn(service, 'getTracks').mockResolvedValue(tracks as never) + jest.spyOn(service, 'getPlugins').mockReturnValue(plugins) + jest.spyOn(service, 'getInternetAccounts').mockReturnValue(internetAccounts) + jest.spyOn(service, 'getDefaultSession').mockReturnValue(defaultSession) + jest.spyOn(service, 'getJBrowseConfig').mockResolvedValue(undefined) + + await expect(service.getConfig(Role.None)).resolves.toEqual({ + configuration, + assemblies, + tracks, + plugins, + internetAccounts, + defaultSession, + }) }) }) diff --git a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts index 0c079de3e..9dd1ebe04 100644 --- a/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts +++ b/packages/apollo-collaboration-server/src/jbrowse/jbrowse.service.ts @@ -231,7 +231,7 @@ export class JBrowseService { } async getConfig(role?: Role) { - if (!role || role === Role.None) { + if (!role) { return { configuration: this.getConfiguration(role), plugins: this.getPlugins(), From a454e10efa0b9391c3cf0fda9d4601e9e5ba24bf Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:23:08 -0400 Subject: [PATCH 03/31] Fix login provider client ID file handling Read client IDs from the file contents after loading MICROSOFT_CLIENT_ID_FILE, GOOGLE_CLIENT_ID_FILE, and LOGINGOV_CLIENT_ID_FILE, and cover the behavior with tests. Assisted-by: GitHub Copilot (GPT-5.3-Codex) --- .../authentication.service.spec.ts | 60 +++++++++++++++---- .../authentication/authentication.service.ts | 4 +- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts b/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts index 51023256a..ec6c7e79b 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts @@ -1,19 +1,59 @@ -import { Test, type TestingModule } from '@nestjs/testing' - import { AuthenticationService } from './authentication.service.js' +import { Role } from '../utils/role/role.enum.js' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' describe('AuthenticationService', () => { - let service: AuthenticationService + function makeService(config: Record) { + const configService = { + get: (key: string) => config[key], + } + + return new AuthenticationService({} as any, {} as any, configService as any) + } - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [AuthenticationService], - }).compile() + it('returns login types from direct client ID values', async () => { + const service = makeService({ + DEFAULT_NEW_USER_ROLE: Role.None, + MICROSOFT_CLIENT_ID: 'ms-direct', + GOOGLE_CLIENT_ID: 'google-direct', + LOGINGOV_CLIENT_ID: 'logingov-direct', + ALLOW_GUEST_USER: true, + }) - service = module.get(AuthenticationService) + await expect(service.getLoginTypes()).resolves.toEqual([ + 'microsoft', + 'google', + 'logingov', + 'guest', + ]) }) - it('should be defined', () => { - expect(service).toBeDefined() + it('returns login types when microsoft and google IDs are configured via files', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'apollo-auth-')) + const msClientIDPath = path.join(tempDir, 'microsoft-client-id') + const googleClientIDPath = path.join(tempDir, 'google-client-id') + const loginGovClientIDPath = path.join(tempDir, 'logingov-client-id') + + await fs.writeFile(msClientIDPath, ' ms-file-id \n') + await fs.writeFile(googleClientIDPath, ' google-file-id \n') + await fs.writeFile(loginGovClientIDPath, ' logingov-file-id \n') + + const service = makeService({ + DEFAULT_NEW_USER_ROLE: Role.None, + MICROSOFT_CLIENT_ID_FILE: msClientIDPath, + GOOGLE_CLIENT_ID_FILE: googleClientIDPath, + LOGINGOV_CLIENT_ID_FILE: loginGovClientIDPath, + ALLOW_GUEST_USER: false, + }) + + await expect(service.getLoginTypes()).resolves.toEqual([ + 'microsoft', + 'google', + 'logingov', + ]) + + await fs.rm(tempDir, { recursive: true, force: true }) }) }) diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.service.ts b/packages/apollo-collaboration-server/src/authentication/authentication.service.ts index 1664ec956..9374f40b3 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.service.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.service.ts @@ -82,7 +82,7 @@ export class AuthenticationService { }) microsoftClientID = clientIDFile && (await fs.readFile(clientIDFile, 'utf8')) - microsoftClientID = clientIDFile?.trim() + microsoftClientID = microsoftClientID?.trim() } let googleClientID = this.configService.get('GOOGLE_CLIENT_ID', { infer: true, @@ -92,7 +92,7 @@ export class AuthenticationService { infer: true, }) googleClientID = clientIDFile && (await fs.readFile(clientIDFile, 'utf8')) - googleClientID = clientIDFile?.trim() + googleClientID = googleClientID?.trim() } let loginGovClientID = this.configService.get('LOGINGOV_CLIENT_ID', { infer: true, From 7c05c9a7aa8ab04b5c5a1b968ba3b61aee4d96c9 Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:11:40 -0400 Subject: [PATCH 04/31] Add assembly ACL management, runtime guards, and CLI test port overrides --- .nvmrc | 1 + CONTRIBUTING.md | 21 ++ package.json | 3 +- packages/apollo-cli/README.md | 109 +++++++- packages/apollo-cli/package.json | 4 + .../src/commands/permissions/README.md | 25 ++ .../src/commands/permissions/grant.ts | 97 +++++++ .../src/commands/permissions/list.ts | 87 ++++++ .../src/commands/permissions/revoke.ts | 81 ++++++ packages/apollo-cli/src/permissionsUtils.ts | 52 ++++ packages/apollo-cli/src/test/README.md | 11 +- packages/apollo-cli/src/test/test.ts | 41 ++- packages/apollo-cli/src/test/test_docker.ts | 9 +- .../apollo-collaboration-server/package.json | 2 +- .../src/app.module.ts | 2 + .../src/assemblyPermissions/README.md | 92 ++++++ .../assemblyPermissions.controller.spec.ts | 35 +++ .../assemblyPermissions.controller.ts | 52 ++++ .../assemblyPermissions.integration.spec.ts | 91 ++++++ .../assemblyPermissions.module.ts | 21 ++ .../assemblyPermissions.service.spec.ts | 99 +++++++ .../assemblyPermissions.service.ts | 95 +++++++ .../dto/update-assembly-permission.dto.ts | 4 + .../src/changes/ASSEMBLY_ACL_NOTES.md | 34 +++ .../src/changes/changes.controller.spec.ts | 13 +- .../src/changes/changes.controller.ts | 5 +- .../src/changes/changes.module.ts | 2 + .../src/changes/changes.service.spec.ts | 84 +++++- .../src/changes/changes.service.ts | 34 ++- .../src/features/features.controller.spec.ts | 12 +- .../src/features/features.controller.ts | 52 +++- .../src/features/features.module.ts | 2 + .../src/features/features.service.spec.ts | 15 +- .../src/features/features.service.ts | 172 ++++++++++-- .../src/assemblyPermission.schema.ts | 51 ++++ packages/apollo-schemas/src/index.ts | 1 + .../MANAGE_USERS_ASSEMBLY_ACL_NOTES.md | 88 ++++++ .../src/components/ManageUsers.tsx | 261 ++++++++++++++++-- .../manageUsersAssemblyPermissions.test.ts | 82 ++++++ .../manageUsersAssemblyPermissions.ts | 62 +++++ .../02-installation/03-login-management.md | 50 ++++ .../04-configuration-options.md | 5 + .../05-assembly-acl-rollout.md | 73 +++++ .../website/docs/03-guides/04-cli/config.md | 4 +- .../website/docs/03-guides/04-cli/help.md | 2 +- .../website/docs/03-guides/04-cli/index.md | 3 + .../docs/03-guides/04-cli/permissions.md | 105 +++++++ 47 files changed, 2128 insertions(+), 118 deletions(-) create mode 100644 .nvmrc create mode 100644 packages/apollo-cli/src/commands/permissions/README.md create mode 100644 packages/apollo-cli/src/commands/permissions/grant.ts create mode 100644 packages/apollo-cli/src/commands/permissions/list.ts create mode 100644 packages/apollo-cli/src/commands/permissions/revoke.ts create mode 100644 packages/apollo-cli/src/permissionsUtils.ts create mode 100644 packages/apollo-collaboration-server/src/assemblyPermissions/README.md create mode 100644 packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.spec.ts create mode 100644 packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.ts create mode 100644 packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.integration.spec.ts create mode 100644 packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.module.ts create mode 100644 packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.spec.ts create mode 100644 packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.ts create mode 100644 packages/apollo-collaboration-server/src/assemblyPermissions/dto/update-assembly-permission.dto.ts create mode 100644 packages/apollo-collaboration-server/src/changes/ASSEMBLY_ACL_NOTES.md create mode 100644 packages/apollo-schemas/src/assemblyPermission.schema.ts create mode 100644 packages/jbrowse-plugin-apollo/src/components/MANAGE_USERS_ASSEMBLY_ACL_NOTES.md create mode 100644 packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.test.ts create mode 100644 packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts create mode 100644 packages/website/docs/02-installation/05-assembly-acl-rollout.md create mode 100644 packages/website/docs/03-guides/04-cli/permissions.md diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a039390b..3dfdf717c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,27 @@ git clone https://github.com/GMOD/Apollo3 You'll need `yarn` to be installed. +## Node version compatibility + +Apollo3 test tooling currently expects Node 20 or 22. + +- Node 26 is known to break Jest 29 in this repository with errors such as + `TypeError: require.resolve.paths is not a function`. +- Run `yarn check:node` before running tests. + +If you use `nvm`, you can pin a compatible runtime with: + +```sh +nvm use +``` + +If you do not use a Node version manager, you can still run commands with Node +22 ad hoc: + +```sh +npx -y node@22 .yarn/releases/yarn-4.14.1.cjs +``` + You then have two options to start Apollo3 for development purposes. In both cases, the instance is then accessible via diff --git a/package.json b/package.json index 3c7d25a8f..08c836dd5 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "packages/*" ], "scripts": { + "check:node": "node -e \"const major=Number(process.versions.node.split('.')[0]); if (major>=26){console.error('Apollo3 test tooling is not compatible with Node '+process.versions.node+'. Use Node 20 or 22.'); process.exit(1)} console.log('Node version OK for Apollo3 test tooling:', process.versions.node)\"", "lint": "cross-env NODE_OPTIONS='--max-old-space-size=4096' yarn eslint --max-warnings 0", "build:shared": "yarn workspace @apollo-annotation/shared run build", "release": "tsx scripts/makeGitTag.ts", @@ -14,7 +15,7 @@ "start:server": "yarn workspace @apollo-annotation/collaboration-server run start", "start:plugin": "yarn workspace @apollo-annotation/jbrowse-plugin-apollo run start", "start": "npm-run-all --print-label --parallel start:shared start:server start:plugin", - "test": "yarn workspaces foreach --all run test:ci", + "test": "yarn check:node && yarn workspaces foreach --all run test:ci", "postinstall": "husky" }, "devDependencies": { diff --git a/packages/apollo-cli/README.md b/packages/apollo-cli/README.md index 5ac99faa7..9b2bb89fa 100644 --- a/packages/apollo-cli/README.md +++ b/packages/apollo-cli/README.md @@ -16,7 +16,7 @@ $ npm install -g @apollo-annotation/cli $ apollo COMMAND running command... $ apollo (--version) -@apollo-annotation/cli/1.0.0 linux-x64 node-v24.15.0 +@apollo-annotation/cli/1.0.0 darwin-arm64 node-v26.0.0 $ apollo --help [COMMAND] USAGE $ apollo COMMAND @@ -62,6 +62,9 @@ USAGE - [`apollo jbrowse set-config INPUTFILE`](#apollo-jbrowse-set-config-inputfile) - [`apollo login`](#apollo-login) - [`apollo logout`](#apollo-logout) +- [`apollo permissions grant`](#apollo-permissions-grant) +- [`apollo permissions list`](#apollo-permissions-list) +- [`apollo permissions revoke`](#apollo-permissions-revoke) - [`apollo refseq add-alias INPUT-FILE`](#apollo-refseq-add-alias-input-file) - [`apollo refseq get`](#apollo-refseq-get) - [`apollo status`](#apollo-status) @@ -335,8 +338,8 @@ DESCRIPTION Address and port e.g http://localhost:3999 - accessType: - How to access Apollo. accessType is typically one of: google, microsoft, logingov, guest, root. Allowed types depend on your - Apollo setup + How to access Apollo. accessType is typically one of: google, microsoft, logingov, guest, root. Allowed types depend + on your Apollo setup - accessToken: Access token. Usually inserted by `apollo login` @@ -1019,7 +1022,7 @@ DESCRIPTION ``` _See code: -[@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.8//workspaces/Apollo3/.yarn/cache/@oclif-plugin-help-npm-6.0.8-336726b8e7-4b3be03d8a.zip/node_modules/@oclif/plugin-help/lib/commands/help.ts)_ +[@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v6.0.8//Users/cchilders/git-repos/Apollo3/.yarn/cache/@oclif-plugin-help-npm-6.0.8-336726b8e7-4b3be03d8a.zip/node_modules/@oclif/plugin-help/lib/commands/help.ts)_ ## `apollo jbrowse desktop JBROWSEFILE` @@ -1190,6 +1193,104 @@ EXAMPLES _See code: [src/commands/logout.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/logout.ts)_ +## `apollo permissions grant` + +Grant assembly permissions to a user + +``` +USAGE + $ apollo permissions grant -u -a [--profile ] [--config-file ] [--view] [--edit] + +FLAGS + -a, --assembly=... (required) Assembly name or id + -u, --user= (required) User id, username, or email + --config-file= Use this config file (mostly for testing) + --edit Grant edit permission (implies view) + --profile= Use credentials from this profile + --view Grant view permission + +DESCRIPTION + Grant assembly permissions to a user + + Grants annotation permissions for one user across one or more assemblies. + +EXAMPLES + Grant view access to one assembly: + + $ apollo permissions grant -u user@example.org -a myAssembly --view + + Grant edit access to multiple assemblies (implies view): + + $ apollo permissions grant -u user@example.org -a asm1 asm2 --edit +``` + +_See code: +[src/commands/permissions/grant.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/permissions/grant.ts)_ + +## `apollo permissions list` + +List assembly permissions + +``` +USAGE + $ apollo permissions list [--profile ] [--config-file ] [-u ] [-a ] + +FLAGS + -a, --assembly= Filter by one assembly name or id + -u, --user= Filter by user id, username, or email + --config-file= Use this config file (mostly for testing) + --profile= Use credentials from this profile + +DESCRIPTION + List assembly permissions + + Lists assembly permission documents, optionally filtered by user and/or assembly. + +EXAMPLES + List all permissions: + + $ apollo permissions list + + List permissions for one user: + + $ apollo permissions list -u user@example.org + + List permissions for one assembly: + + $ apollo permissions list -a myAssembly +``` + +_See code: +[src/commands/permissions/list.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/permissions/list.ts)_ + +## `apollo permissions revoke` + +Revoke assembly permissions from a user + +``` +USAGE + $ apollo permissions revoke -u -a [--profile ] [--config-file ] + +FLAGS + -a, --assembly=... (required) Assembly name or id + -u, --user= (required) User id, username, or email + --config-file= Use this config file (mostly for testing) + --profile= Use credentials from this profile + +DESCRIPTION + Revoke assembly permissions from a user + + Revokes annotation permissions for one user across one or more assemblies. + +EXAMPLES + Revoke access from one assembly: + + $ apollo permissions revoke -u user@example.org -a myAssembly +``` + +_See code: +[src/commands/permissions/revoke.ts](https://github.com/GMOD/Apollo3/blob/v1.0.0/packages/apollo-cli/src/commands/permissions/revoke.ts)_ + ## `apollo refseq add-alias INPUT-FILE` Add reference name aliases from a file diff --git a/packages/apollo-cli/package.json b/packages/apollo-cli/package.json index 2db7aca08..fa54803db 100644 --- a/packages/apollo-cli/package.json +++ b/packages/apollo-cli/package.json @@ -27,6 +27,7 @@ "prepare": "yarn build", "test": "yarn tsx src/test/test.ts", "test:ci": "yarn tsx src/test/test.ts", + "test:permissions": "yarn tsx --test-name-pattern='Permissions grant/list/revoke' src/test/test.ts", "version": "oclif readme --multi --dir ../website/docs/03-guides/04-cli/ && oclif readme && git add README.md" }, "oclif": { @@ -61,6 +62,9 @@ "jbrowse": { "description": "Commands to manage the JBrowse configuration" }, + "permissions": { + "description": "Commands to manage assembly permissions" + }, "refseq": { "description": "Commands to manage reference sequences" }, diff --git a/packages/apollo-cli/src/commands/permissions/README.md b/packages/apollo-cli/src/commands/permissions/README.md new file mode 100644 index 000000000..a71e59656 --- /dev/null +++ b/packages/apollo-cli/src/commands/permissions/README.md @@ -0,0 +1,25 @@ +# CLI Permissions Commands + +This folder defines assembly ACL management commands for the Apollo CLI. + +Commands: + +- `apollo permissions list` +- `apollo permissions grant` +- `apollo permissions revoke` + +Resolution behavior: + +- User input supports user id, username, or email. +- Assembly input supports assembly id or common assembly name. + +Grant semantics: + +- `--edit` implies `canViewAnnotations=true`. +- If no explicit permission flags are provided to `grant`, it defaults to + view-only. + +Implementation notes: + +- Commands call collaboration-server endpoint `assemblyPermissions`. +- `grant` and `revoke` perform `PUT` upserts per assembly. diff --git a/packages/apollo-cli/src/commands/permissions/grant.ts b/packages/apollo-cli/src/commands/permissions/grant.ts new file mode 100644 index 000000000..8a6754393 --- /dev/null +++ b/packages/apollo-cli/src/commands/permissions/grant.ts @@ -0,0 +1,97 @@ +import { Flags } from '@oclif/core' +import { type RequestInit, fetch } from 'undici' + +import { BaseCommand } from '../../baseCommand.js' +import { createFetchErrorMessage, localhostToAddress } from '../../utils.js' +import { resolveAssemblyIds, resolveUserId } from '../../permissionsUtils.js' + +export default class Grant extends BaseCommand { + static summary = 'Grant assembly permissions to a user' + static description = + 'Grants annotation permissions for one user across one or more assemblies.' + + static examples = [ + { + description: 'Grant view access to one assembly:', + command: + '<%= config.bin %> <%= command.id %> -u user@example.org -a myAssembly --view', + }, + { + description: 'Grant edit access to multiple assemblies (implies view):', + command: + '<%= config.bin %> <%= command.id %> -u user@example.org -a asm1 asm2 --edit', + }, + ] + + static flags = { + user: Flags.string({ + char: 'u', + description: 'User id, username, or email', + required: true, + }), + assembly: Flags.string({ + char: 'a', + description: 'Assembly name or id', + multiple: true, + required: true, + }), + view: Flags.boolean({ + description: 'Grant view permission', + default: false, + }), + edit: Flags.boolean({ + description: 'Grant edit permission (implies view)', + default: false, + }), + } + + public async run(): Promise { + const { flags } = await this.parse(Grant) + const access = await this.getAccess() + + const userId = await resolveUserId( + access.address, + access.accessToken, + flags.user, + ) + const assemblyIds = await resolveAssemblyIds( + access.address, + access.accessToken, + flags.assembly, + ) + + const canEditAnnotations = flags.edit + const canViewAnnotations = flags.view || canEditAnnotations || !flags.edit + + const updated = [] + for (const assemblyId of assemblyIds) { + const auth: RequestInit = { + method: 'PUT', + body: JSON.stringify({ + canViewAnnotations, + canEditAnnotations, + }), + headers: { + Authorization: `Bearer ${access.accessToken}`, + 'Content-Type': 'application/json', + }, + } + const url = new URL( + localhostToAddress( + `${access.address}/assemblyPermissions/${userId}/${assemblyId}`, + ), + ) + const response = await fetch(url, auth) + if (!response.ok) { + const errorMessage = await createFetchErrorMessage( + response, + `grant failed for assembly '${assemblyId}'`, + ) + throw new Error(errorMessage) + } + updated.push(await response.json()) + } + + this.log(JSON.stringify(updated, null, 2)) + } +} diff --git a/packages/apollo-cli/src/commands/permissions/list.ts b/packages/apollo-cli/src/commands/permissions/list.ts new file mode 100644 index 000000000..b702c8f79 --- /dev/null +++ b/packages/apollo-cli/src/commands/permissions/list.ts @@ -0,0 +1,87 @@ +import { Flags } from '@oclif/core' +import type { Response } from 'undici' + +import { BaseCommand } from '../../baseCommand.js' +import { resolveAssemblyIds, resolveUserId } from '../../permissionsUtils.js' +import { queryApollo } from '../../utils.js' + +export default class List extends BaseCommand { + static summary = 'List assembly permissions' + static description = + 'Lists assembly permission documents, optionally filtered by user and/or assembly.' + + static examples = [ + { + description: 'List all permissions:', + command: '<%= config.bin %> <%= command.id %>', + }, + { + description: 'List permissions for one user:', + command: '<%= config.bin %> <%= command.id %> -u user@example.org', + }, + { + description: 'List permissions for one assembly:', + command: '<%= config.bin %> <%= command.id %> -a myAssembly', + }, + ] + + static flags = { + user: Flags.string({ + char: 'u', + description: 'Filter by user id, username, or email', + }), + assembly: Flags.string({ + char: 'a', + description: 'Filter by one assembly name or id', + }), + } + + public async run(): Promise { + const { flags } = await this.parse(List) + const access = await this.getAccess() + + let userId: string | undefined + if (flags.user) { + userId = await resolveUserId( + access.address, + access.accessToken, + flags.user, + ) + } + + let assemblyId: string | undefined + if (flags.assembly) { + const assemblyIds = await resolveAssemblyIds( + access.address, + access.accessToken, + [flags.assembly], + ) + if (assemblyIds.length > 1) { + throw new Error( + `Assembly '${flags.assembly}' resolved to multiple ids, use an id to disambiguate`, + ) + } + assemblyId = assemblyIds[0] + } + + const queryParams = new URLSearchParams() + if (userId) { + queryParams.set('userId', userId) + } + if (assemblyId) { + queryParams.set('assemblyId', assemblyId) + } + + const endpoint = queryParams.size + ? `assemblyPermissions?${queryParams.toString()}` + : 'assemblyPermissions' + + const response: Response = await queryApollo( + access.address, + access.accessToken, + endpoint, + ) + const permissions = (await response.json()) as object[] + this.log(JSON.stringify(permissions, null, 2)) + } +} diff --git a/packages/apollo-cli/src/commands/permissions/revoke.ts b/packages/apollo-cli/src/commands/permissions/revoke.ts new file mode 100644 index 000000000..48013b570 --- /dev/null +++ b/packages/apollo-cli/src/commands/permissions/revoke.ts @@ -0,0 +1,81 @@ +import { Flags } from '@oclif/core' +import { type RequestInit, fetch } from 'undici' + +import { BaseCommand } from '../../baseCommand.js' +import { createFetchErrorMessage, localhostToAddress } from '../../utils.js' +import { resolveAssemblyIds, resolveUserId } from '../../permissionsUtils.js' + +export default class Revoke extends BaseCommand { + static summary = 'Revoke assembly permissions from a user' + static description = + 'Revokes annotation permissions for one user across one or more assemblies.' + + static examples = [ + { + description: 'Revoke access from one assembly:', + command: + '<%= config.bin %> <%= command.id %> -u user@example.org -a myAssembly', + }, + ] + + static flags = { + user: Flags.string({ + char: 'u', + description: 'User id, username, or email', + required: true, + }), + assembly: Flags.string({ + char: 'a', + description: 'Assembly name or id', + multiple: true, + required: true, + }), + } + + public async run(): Promise { + const { flags } = await this.parse(Revoke) + const access = await this.getAccess() + + const userId = await resolveUserId( + access.address, + access.accessToken, + flags.user, + ) + const assemblyIds = await resolveAssemblyIds( + access.address, + access.accessToken, + flags.assembly, + ) + + const updated = [] + for (const assemblyId of assemblyIds) { + const auth: RequestInit = { + method: 'PUT', + body: JSON.stringify({ + canViewAnnotations: false, + canEditAnnotations: false, + }), + headers: { + Authorization: `Bearer ${access.accessToken}`, + 'Content-Type': 'application/json', + }, + } + const url = new URL( + localhostToAddress( + `${access.address}/assemblyPermissions/${userId}/${assemblyId}`, + ), + ) + const response = await fetch(url, auth) + if (!response.ok) { + const errorMessage = await createFetchErrorMessage( + response, + `revoke failed for assembly '${assemblyId}'`, + ) + throw new Error(errorMessage) + } + updated.push(await response.json()) + } + + this.log(JSON.stringify(updated, null, 2)) + } +} diff --git a/packages/apollo-cli/src/permissionsUtils.ts b/packages/apollo-cli/src/permissionsUtils.ts new file mode 100644 index 000000000..21d8c4e8f --- /dev/null +++ b/packages/apollo-cli/src/permissionsUtils.ts @@ -0,0 +1,52 @@ +import type { Response } from 'undici' + +import { convertAssemblyNameToId, idReader, queryApollo } from './utils.js' + +interface UserDoc { + _id: string + username: string + email: string +} + +export async function resolveUserId( + address: string, + accessToken: string, + userInput: string, +): Promise { + const usersResponse: Response = await queryApollo( + address, + accessToken, + 'users', + ) + const users = (await usersResponse.json()) as UserDoc[] + + const matches = users.filter( + (user) => + user._id === userInput || + user.username === userInput || + user.email === userInput, + ) + + if (matches.length === 0) { + throw new Error(`User '${userInput}' not found`) + } + if (matches.length > 1) { + throw new Error( + `User '${userInput}' is ambiguous. Use a unique user id instead.`, + ) + } + return matches[0]._id +} + +export async function resolveAssemblyIds( + address: string, + accessToken: string, + assemblyInputs: string[], +): Promise { + const parsedInputs = await idReader(assemblyInputs) + const ids = await convertAssemblyNameToId(address, accessToken, parsedInputs) + if (ids.length === 0) { + throw new Error('No assemblies matched the input provided') + } + return ids +} diff --git a/packages/apollo-cli/src/test/README.md b/packages/apollo-cli/src/test/README.md index 508bb942c..50bcf7048 100644 --- a/packages/apollo-cli/src/test/README.md +++ b/packages/apollo-cli/src/test/README.md @@ -35,7 +35,7 @@ cd Apollo3/packages/apollo-cli - To run all tests: ``` -* yarn tsx src/test/test.ts +yarn tsx src/test/test.ts ``` - To run only tests matching aregular expression: @@ -44,8 +44,15 @@ cd Apollo3/packages/apollo-cli yarn tsx --test-name-pattern='Print help|Feature get' src/test/test.ts ``` +- To run against a non-default Apollo server address (default is + `http://localhost:3999`): + +``` +APOLLO_TEST_ADDRESS=http://localhost:4001 yarn tsx src/test/test.ts +``` + # Run docker test ``` -yarn tsx ./src/test/test_docker.ts +APOLLO_TEST_ADDRESS=http://localhost:4001 yarn tsx ./src/test/test_docker.ts ``` diff --git a/packages/apollo-cli/src/test/test.ts b/packages/apollo-cli/src/test/test.ts index 03cba1d35..937bc46ae 100644 --- a/packages/apollo-cli/src/test/test.ts +++ b/packages/apollo-cli/src/test/test.ts @@ -32,6 +32,7 @@ import { Shell, deleteAllChecks } from './utils.js' const apollo = 'yarn dev' const P = '--profile testAdmin' +const testAddress = process.env.APOLLO_TEST_ADDRESS ?? 'http://localhost:3999' // let client = MongoClient let client: MongoClient let configFile = '' @@ -49,7 +50,7 @@ void describe('Test CLI', () => { `Backup config file ${configFileBak} already exists. If safe to do so, delete it before testing`, ) } - new Shell(`${apollo} config ${P} address http://localhost:3999`) + new Shell(`${apollo} config ${P} address ${testAddress}`) new Shell(`${apollo} config ${P} accessType root`) new Shell(`${apollo} config ${P} rootPassword pass`) new Shell(`${apollo} login ${P} -f`) @@ -98,7 +99,7 @@ void describe('Test CLI', () => { assert.strictEqual(1, p.returncode) assert.ok(p.stderr.includes('Invalid setting:')) - p = new Shell(`${apollo} config ${P} ADDRESS http://localhost:3999`, false) + p = new Shell(`${apollo} config ${P} ADDRESS ${testAddress}`, false) assert.strictEqual(1, p.returncode) assert.ok(p.stderr.includes('Invalid setting:')) @@ -912,7 +913,7 @@ EOF`, const token = p.stdout.trim() const newChildFeatureID = '69408088d502fc21aea1bb0a' new Shell( - `curl -X POST http://127.0.0.1:3999/changes -d '{"typeName":"AddFeatureChange","changedIds":["${newChildFeatureID}"],"assembly":"${assembly}","addedFeature":{"_id":"${newChildFeatureID}","refSeq":"${refSeq}","min":311,"max":315,"type":"match_part","attributes":{"gff_id":["matchPart2"]}},"parentFeatureId":"${_id}"}' -H "Content-Type: application/json" -H "Authorization: Bearer ${token}"`, + `curl -X POST ${testAddress}/changes -d '{"typeName":"AddFeatureChange","changedIds":["${newChildFeatureID}"],"assembly":"${assembly}","addedFeature":{"_id":"${newChildFeatureID}","refSeq":"${refSeq}","min":311,"max":315,"type":"match_part","attributes":{"gff_id":["matchPart2"]}},"parentFeatureId":"${_id}"}' -H "Content-Type: application/json" -H "Authorization: Bearer ${token}"`, ) p = new Shell(`${apollo} feature get-indexed-id ${P} matchPart2 -a vv1`) out = JSON.parse(p.stdout) @@ -1429,10 +1430,36 @@ EOF`, assert.strictEqual(out.length, 0) }) + void globalThis.itName('Permissions grant/list/revoke', () => { + new Shell( + `${apollo} assembly add-from-fasta ${P} test_data/tiny.fasta -a permAsm -e -f`, + ) + + let p = new Shell(`${apollo} user get ${P} -r admin -u root`) + const adminUser = JSON.parse(p.stdout)[0] + const userId = adminUser._id + + new Shell(`${apollo} permissions grant ${P} -u ${userId} -a permAsm --edit`) + + p = new Shell(`${apollo} permissions list ${P} -u ${userId} -a permAsm`) + let out = JSON.parse(p.stdout) + assert.strictEqual(out.length, 1) + assert.strictEqual(out[0].canViewAnnotations, true) + assert.strictEqual(out[0].canEditAnnotations, true) + + new Shell(`${apollo} permissions revoke ${P} -u ${userId} -a permAsm`) + + p = new Shell(`${apollo} permissions list ${P} -u ${userId} -a permAsm`) + out = JSON.parse(p.stdout) + assert.strictEqual(out.length, 1) + assert.strictEqual(out[0].canViewAnnotations, false) + assert.strictEqual(out[0].canEditAnnotations, false) + }) + void globalThis.itName('Apollo profile env', () => { const p = new Shell( `export APOLLO_PROFILE=testAdmin2 - ${apollo} config address http://localhost:3999 + ${apollo} config address ${testAddress} ${apollo} config accessType root ${apollo} config rootPassword pass ${apollo} login -f @@ -1448,7 +1475,7 @@ EOF`, `\ export APOLLO_DISABLE_CONFIG_CREATE=1 rm -f tmp.yml - ${apollo} config --config-file tmp.yml address http://localhost:3999`, + ${apollo} config --config-file tmp.yml address ${testAddress}`, false, ) assert.ok(p.returncode != 0) @@ -1459,7 +1486,7 @@ EOF`, `\ export APOLLO_DISABLE_CONFIG_CREATE=0 rm -f tmp.yml - ${apollo} config --config-file tmp.yml address http://localhost:3999`, + ${apollo} config --config-file tmp.yml address ${testAddress}`, ) assert.strictEqual(0, p.returncode) assert.ok(fs.existsSync('tmp.yml')) @@ -1468,7 +1495,7 @@ EOF`, `\ unset APOLLO_DISABLE_CONFIG_CREATE rm -f tmp.yml - ${apollo} config --config-file tmp.yml address http://localhost:3999`, + ${apollo} config --config-file tmp.yml address ${testAddress}`, ) assert.strictEqual(0, p.returncode) assert.ok(fs.existsSync('tmp.yml')) diff --git a/packages/apollo-cli/src/test/test_docker.ts b/packages/apollo-cli/src/test/test_docker.ts index 01f7c374e..9dfa06c77 100644 --- a/packages/apollo-cli/src/test/test_docker.ts +++ b/packages/apollo-cli/src/test/test_docker.ts @@ -9,6 +9,7 @@ const hostTmpDir = path.resolve('tmpTestDocker') const hostDataDir = path.resolve('test_data') const apollo = `docker run --network host -v ${hostTmpDir}:/root/.config/apollo-cli -v ${hostDataDir}:/data apollo` const configFile = '/root/.config/apollo-cli/config.yml' +const testAddress = process.env.APOLLO_TEST_ADDRESS ?? 'http://localhost:3999' void describe('Test Docker', () => { before(() => { @@ -22,7 +23,7 @@ void describe('Test Docker', () => { // See apollo-collaboration-server/.development.env for credentials etc. new Shell( - `${apollo} config --config-file ${configFile} address http://localhost:3999`, + `${apollo} config --config-file ${configFile} address ${testAddress}`, ) new Shell(`${apollo} config --config-file ${configFile} accessType root`) new Shell(`${apollo} config --config-file ${configFile} rootPassword pass`) @@ -55,14 +56,14 @@ void describe('Test Docker', () => { void globalThis.itName('Missing config', () => { let p = new Shell( - `${apollo} config address --config-file {hostTmpDir}/new.yml http://localhost:3999`, + `${apollo} config address --config-file {hostTmpDir}/new.yml ${testAddress}`, false, ) assert.ok(p.returncode != 0) assert.ok(p.stderr.includes('does not exist yet')) p = new Shell( - `${apollo} config address --config-file /root/.config/apollo-cli/new.yml http://localhost:3999`, + `${apollo} config address --config-file /root/.config/apollo-cli/new.yml ${testAddress}`, false, ) assert.ok(p.returncode != 0) @@ -70,7 +71,7 @@ void describe('Test Docker', () => { fs.writeFileSync(path.join(hostTmpDir, 'new.yml'), '') p = new Shell( - `${apollo} config address --config-file /root/.config/apollo-cli/new.yml http://localhost:3999`, + `${apollo} config address --config-file /root/.config/apollo-cli/new.yml ${testAddress}`, ) assert.strictEqual(p.returncode, 0) }) diff --git a/packages/apollo-collaboration-server/package.json b/packages/apollo-collaboration-server/package.json index fec318750..aa7596abc 100644 --- a/packages/apollo-collaboration-server/package.json +++ b/packages/apollo-collaboration-server/package.json @@ -15,7 +15,7 @@ "start:no-watch": "yarn build:shared && yarn start:nest", "start:prod": "NODE_ENV=production node dist/main.js", "test": "jest", - "test:cli:start": "ALLOW_ROOT_USER=true ROOT_USER_PASSWORD=pass MONGODB_URI=\"mongodb://localhost:27017/apolloTestCliDb?directConnection=true\" LOG_LEVELS=error,warn yarn start", + "test:cli:start": "ALLOW_ROOT_USER=true ROOT_USER_PASSWORD=pass MONGODB_URI=\"mongodb://localhost:27017/apolloTestCliDb?directConnection=true\" LOG_LEVELS=error,warn PORT=${APOLLO_TEST_PORT:-3999} yarn start", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", diff --git a/packages/apollo-collaboration-server/src/app.module.ts b/packages/apollo-collaboration-server/src/app.module.ts index 2c252ccb5..04434bc1d 100644 --- a/packages/apollo-collaboration-server/src/app.module.ts +++ b/packages/apollo-collaboration-server/src/app.module.ts @@ -11,6 +11,7 @@ import Joi from 'joi' import type { Connection } from 'mongoose' import { AssembliesModule } from './assemblies/assemblies.module.js' +import { AssemblyPermissionsModule } from './assemblyPermissions/assemblyPermissions.module.js' import { AuthenticationModule } from './authentication/authentication.module.js' import { ChangesModule } from './changes/changes.module.js' import { ChecksModule } from './checks/checks.module.js' @@ -157,6 +158,7 @@ async function mongoDBURIFactory( @Module({ imports: [ AssembliesModule, + AssemblyPermissionsModule, AuthenticationModule, ChangesModule, ChecksModule, diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/README.md b/packages/apollo-collaboration-server/src/assemblyPermissions/README.md new file mode 100644 index 000000000..b410da696 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/README.md @@ -0,0 +1,92 @@ +# Assembly Permissions Module + +This module introduces per-assembly Apollo annotation permissions. + +## What this module owns + +- Persistence of user x assembly grants (`canViewAnnotations`, + `canEditAnnotations`) +- Admin CRUD API for grants +- Helper methods consumed by other modules (for example, write-path ACL checks + in changes) + +## Data model + +The schema is defined in: + +- `@apollo-annotation/schemas/src/assemblyPermission.schema.ts` + +Key invariants: + +- Unique grant document per `(userId, assemblyId)` +- `canEditAnnotations=true` always implies `canViewAnnotations=true` + +## Endpoints + +All endpoints are admin-only via `@Validations(Role.Admin)`. + +- `GET /assemblyPermissions?userId=...&assemblyId=...` + - list by optional filters +- `GET /assemblyPermissions/byUser/:userId` + - list all grants for a user +- `GET /assemblyPermissions/byAssembly/:assemblyId` + - list all grants for an assembly +- `PUT /assemblyPermissions/:userId/:assemblyId` + - upsert one grant + +Request body for `PUT`: + +```json +{ + "canViewAnnotations": true, + "canEditAnnotations": false +} +``` + +The controller extracts `req.user` and stores the caller as +`createdBy`/`updatedBy` metadata. + +## Slice 2 write-path enforcement + +`ChangesService.create()` now enforces assembly edit grants before applying +assembly-specific changes: + +- Admin users bypass assembly grant checks +- Non-admin users must satisfy `canEdit(user.id, change.assembly)` +- Unauthorized write attempts return `UnprocessableEntityException` + +Implementation location: + +- `changes/changes.service.ts` + +## Slice 2b read-path enforcement + +Read operations now apply assembly view permissions. + +- Features read endpoints (range, text search, count, get by id/indexed id, + list) only return data from assemblies the caller can view +- Change history queries are filtered to assemblies the caller can view +- Admin users bypass assembly ACL filtering + +Implementation locations: + +- `features/features.controller.ts` +- `features/features.service.ts` +- `changes/changes.controller.ts` +- `changes/changes.service.ts` + +## Testing + +Current tests for this module: + +- `assemblyPermissions.service.spec.ts` (service behavior and upsert rule) +- `assemblyPermissions.controller.spec.ts` (controller definition) +- `assemblyPermissions.integration.spec.ts` (HTTP route wiring with mocked + service) + +Recommended future test additions: + +- Persistence-level tests against a test MongoDB (compound index + validation + hook) +- Integration tests with auth guards enabled +- End-to-end tests validating denied/allowed write changes through `/changes` diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.spec.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.spec.ts new file mode 100644 index 000000000..9e8ea0335 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.spec.ts @@ -0,0 +1,35 @@ +import { Test, type TestingModule } from '@nestjs/testing' + +import { AssemblyPermissionsController } from './assemblyPermissions.controller.js' +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' + +describe('AssemblyPermissionsController', () => { + let controller: AssemblyPermissionsController + + const serviceMock = { + find: jest.fn(), + findByUser: jest.fn(), + findByAssembly: jest.fn(), + upsertPermission: jest.fn(), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AssemblyPermissionsController], + providers: [ + { + provide: AssemblyPermissionsService, + useValue: serviceMock, + }, + ], + }).compile() + + controller = module.get( + AssemblyPermissionsController, + ) + }) + + it('should be defined', () => { + expect(controller).toBeDefined() + }) +}) diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.ts new file mode 100644 index 000000000..d03bb25a9 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.controller.ts @@ -0,0 +1,52 @@ +import type { DecodedJWT } from '@apollo-annotation/shared' +import { Body, Controller, Get, Param, Put, Query, Req } from '@nestjs/common' +import type { Request } from 'express' + +import { Role } from '../utils/role/role.enum.js' +import { Validations } from '../utils/validation/validatation.decorator.js' + +import { UpdateAssemblyPermissionDto } from './dto/update-assembly-permission.dto.js' +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' + +@Validations(Role.Admin) +@Controller('assemblyPermissions') +export class AssemblyPermissionsController { + constructor( + private readonly assemblyPermissionsService: AssemblyPermissionsService, + ) {} + + @Get() + find( + @Query('userId') userId?: string, + @Query('assemblyId') assemblyId?: string, + ) { + return this.assemblyPermissionsService.find(userId, assemblyId) + } + + @Get('byUser/:userId') + findByUser(@Param('userId') userId: string) { + return this.assemblyPermissionsService.findByUser(userId) + } + + @Get('byAssembly/:assemblyId') + findByAssembly(@Param('assemblyId') assemblyId: string) { + return this.assemblyPermissionsService.findByAssembly(assemblyId) + } + + @Put(':userId/:assemblyId') + upsertPermission( + @Param('userId') userId: string, + @Param('assemblyId') assemblyId: string, + @Body() permission: UpdateAssemblyPermissionDto, + @Req() req: Request, + ) { + const { user } = req as unknown as { user?: DecodedJWT } + const actor = user?.email || user?.username + return this.assemblyPermissionsService.upsertPermission( + userId, + assemblyId, + permission, + actor, + ) + } +} diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.integration.spec.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.integration.spec.ts new file mode 100644 index 000000000..7bacf8305 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.integration.spec.ts @@ -0,0 +1,91 @@ +import { INestApplication } from '@nestjs/common' +import { Test, type TestingModule } from '@nestjs/testing' +import request from 'supertest' + +import { AssemblyPermissionsController } from './assemblyPermissions.controller.js' +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' + +describe('AssemblyPermissionsController (integration)', () => { + let app: INestApplication + + const serviceMock = { + find: jest.fn().mockResolvedValue([]), + findByUser: jest.fn().mockResolvedValue([]), + findByAssembly: jest.fn().mockResolvedValue([]), + upsertPermission: jest.fn().mockResolvedValue({ + userId: 'user1', + assemblyId: 'assembly1', + canViewAnnotations: true, + canEditAnnotations: true, + }), + } + + beforeEach(async () => { + jest.clearAllMocks() + const moduleFixture: TestingModule = await Test.createTestingModule({ + controllers: [AssemblyPermissionsController], + providers: [ + { + provide: AssemblyPermissionsService, + useValue: serviceMock, + }, + ], + }).compile() + + app = moduleFixture.createNestApplication() + app.use((req, _res, next) => { + req.user = { + username: 'admin', + email: 'admin@test', + } + next() + }) + await app.init() + }) + + afterEach(async () => { + await app.close() + }) + + it('GET /assemblyPermissions forwards query filters', async () => { + await request(app.getHttpServer()) + .get('/assemblyPermissions') + .query({ userId: 'u1', assemblyId: 'a1' }) + .expect(200) + + expect(serviceMock.find).toHaveBeenCalledWith('u1', 'a1') + }) + + it('GET /assemblyPermissions/byUser/:userId forwards user id', async () => { + await request(app.getHttpServer()) + .get('/assemblyPermissions/byUser/u1') + .expect(200) + + expect(serviceMock.findByUser).toHaveBeenCalledWith('u1') + }) + + it('GET /assemblyPermissions/byAssembly/:assemblyId forwards assembly id', async () => { + await request(app.getHttpServer()) + .get('/assemblyPermissions/byAssembly/a1') + .expect(200) + + expect(serviceMock.findByAssembly).toHaveBeenCalledWith('a1') + }) + + it('PUT /assemblyPermissions/:userId/:assemblyId includes actor from req.user', async () => { + await request(app.getHttpServer()) + .put('/assemblyPermissions/u1/a1') + .send({ canViewAnnotations: false, canEditAnnotations: true }) + .expect(200) + + expect(serviceMock.upsertPermission).toHaveBeenCalledWith( + 'u1', + 'a1', + { + canViewAnnotations: false, + canEditAnnotations: true, + }, + 'admin@test', + ) + }) +}) diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.module.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.module.ts new file mode 100644 index 000000000..9dbb62b43 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.module.ts @@ -0,0 +1,21 @@ +import { + AssemblyPermission, + AssemblyPermissionSchema, +} from '@apollo-annotation/schemas' +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' + +import { AssemblyPermissionsController } from './assemblyPermissions.controller.js' +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' + +@Module({ + controllers: [AssemblyPermissionsController], + providers: [AssemblyPermissionsService], + imports: [ + MongooseModule.forFeature([ + { name: AssemblyPermission.name, schema: AssemblyPermissionSchema }, + ]), + ], + exports: [AssemblyPermissionsService, MongooseModule], +}) +export class AssemblyPermissionsModule {} diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.spec.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.spec.ts new file mode 100644 index 000000000..1920c4882 --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.spec.ts @@ -0,0 +1,99 @@ +import { Test, type TestingModule } from '@nestjs/testing' +import { getModelToken } from '@nestjs/mongoose' + +import { AssemblyPermissionsService } from './assemblyPermissions.service.js' +import { UpdateAssemblyPermissionDto } from './dto/update-assembly-permission.dto.js' + +describe('AssemblyPermissionsService', () => { + let service: AssemblyPermissionsService + + const execMock = jest.fn() + const findMock = jest.fn(() => ({ exec: execMock })) + const findOneMock = jest.fn(() => ({ exec: execMock })) + const findOneAndUpdateMock = jest.fn(() => ({ exec: execMock })) + + const modelMock = { + find: findMock, + findOne: findOneMock, + findOneAndUpdate: findOneAndUpdateMock, + } + + beforeEach(async () => { + jest.clearAllMocks() + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssemblyPermissionsService, + { + provide: getModelToken('AssemblyPermission'), + useValue: modelMock, + }, + ], + }).compile() + + service = module.get(AssemblyPermissionsService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('find should pass query params to model', async () => { + execMock.mockResolvedValueOnce([]) + + await service.find('user123', 'assembly456') + + expect(findMock).toHaveBeenCalledWith({ + userId: 'user123', + assemblyId: 'assembly456', + }) + }) + + it('upsertPermission should force canViewAnnotations true when canEditAnnotations is true', async () => { + execMock.mockResolvedValueOnce({}) + const dto: UpdateAssemblyPermissionDto = { + canViewAnnotations: false, + canEditAnnotations: true, + } + + await service.upsertPermission('user123', 'assembly456', dto, 'admin@test') + + expect(findOneAndUpdateMock).toHaveBeenCalledWith( + { userId: 'user123', assemblyId: 'assembly456' }, + { + $set: { + canViewAnnotations: true, + canEditAnnotations: true, + updatedBy: 'admin@test', + }, + $setOnInsert: { + createdBy: 'admin@test', + }, + }, + { + new: true, + upsert: true, + runValidators: true, + }, + ) + }) + + it('canEdit should return true when matching permission has canEditAnnotations', async () => { + execMock.mockResolvedValueOnce({ canEditAnnotations: true }) + + const result = await service.canEdit('user123', 'assembly456') + + expect(findOneMock).toHaveBeenCalledWith({ + userId: 'user123', + assemblyId: 'assembly456', + }) + expect(result).toBe(true) + }) + + it('canEdit should return false when no matching permission exists', async () => { + execMock.mockResolvedValueOnce(null) + + const result = await service.canEdit('user123', 'assembly456') + + expect(result).toBe(false) + }) +}) diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.ts new file mode 100644 index 000000000..09bd9f81e --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/assemblyPermissions.service.ts @@ -0,0 +1,95 @@ +import { + AssemblyPermission, + type AssemblyPermissionDocument, +} from '@apollo-annotation/schemas' +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { type FilterQuery, Model } from 'mongoose' + +import { UpdateAssemblyPermissionDto } from './dto/update-assembly-permission.dto.js' + +@Injectable() +export class AssemblyPermissionsService { + constructor( + @InjectModel(AssemblyPermission.name) + private readonly assemblyPermissionModel: Model, + ) {} + + find( + userId?: string, + assemblyId?: string, + ): Promise { + const query: FilterQuery = {} + if (userId) { + query.userId = userId + } + if (assemblyId) { + query.assemblyId = assemblyId + } + return this.assemblyPermissionModel.find(query).exec() + } + + findByUser(userId: string): Promise { + return this.assemblyPermissionModel.find({ userId }).exec() + } + + findByAssembly(assemblyId: string): Promise { + return this.assemblyPermissionModel.find({ assemblyId }).exec() + } + + findOne( + userId: string, + assemblyId: string, + ): Promise { + return this.assemblyPermissionModel.findOne({ userId, assemblyId }).exec() + } + + async canEdit(userId: string, assemblyId: string): Promise { + const permission = await this.findOne(userId, assemblyId) + return Boolean(permission?.canEditAnnotations) + } + + async canView(userId: string, assemblyId: string): Promise { + const permission = await this.findOne(userId, assemblyId) + return Boolean(permission?.canViewAnnotations) + } + + async getViewableAssemblyIds(userId: string): Promise { + const permissions = await this.assemblyPermissionModel + .find({ userId, canViewAnnotations: true }) + .select('assemblyId') + .exec() + return permissions.map((permission) => permission.assemblyId.toString()) + } + + upsertPermission( + userId: string, + assemblyId: string, + permission: UpdateAssemblyPermissionDto, + actor?: string, + ): Promise { + const canViewAnnotations = + permission.canViewAnnotations || permission.canEditAnnotations + + return this.assemblyPermissionModel + .findOneAndUpdate( + { userId, assemblyId }, + { + $set: { + canViewAnnotations, + canEditAnnotations: permission.canEditAnnotations, + updatedBy: actor, + }, + $setOnInsert: { + createdBy: actor, + }, + }, + { + new: true, + upsert: true, + runValidators: true, + }, + ) + .exec() + } +} diff --git a/packages/apollo-collaboration-server/src/assemblyPermissions/dto/update-assembly-permission.dto.ts b/packages/apollo-collaboration-server/src/assemblyPermissions/dto/update-assembly-permission.dto.ts new file mode 100644 index 000000000..0dceca48b --- /dev/null +++ b/packages/apollo-collaboration-server/src/assemblyPermissions/dto/update-assembly-permission.dto.ts @@ -0,0 +1,4 @@ +export class UpdateAssemblyPermissionDto { + canViewAnnotations: boolean + canEditAnnotations: boolean +} diff --git a/packages/apollo-collaboration-server/src/changes/ASSEMBLY_ACL_NOTES.md b/packages/apollo-collaboration-server/src/changes/ASSEMBLY_ACL_NOTES.md new file mode 100644 index 000000000..8e25b85b0 --- /dev/null +++ b/packages/apollo-collaboration-server/src/changes/ASSEMBLY_ACL_NOTES.md @@ -0,0 +1,34 @@ +# Assembly ACL in Changes Service + +## Summary + +Write-path ACL is enforced in `ChangesService.create()` before any mutation +handlers run. + +## Rule + +For assembly-specific changes: + +- `admin` role: allowed (bypass) +- `user` / `readOnly` / `none`: requires + `AssemblyPermissionsService.canEdit(user.id, change.assembly)` + +If unauthorized, the service throws `UnprocessableEntityException`. + +## Why this is here + +This check prevents unauthorized API clients from bypassing UI restrictions by +posting changes directly. UI-level hiding/disable logic is not sufficient for +security. + +## Scope currently covered + +- Assembly-scoped write operations submitted through `/changes` +- Change history reads through `/changes` are filtered by assembly view grants + for non-admin users + +## Follow-up slices + +- Read-path assembly ACL filtering in features/search/count endpoints +- Optional stricter status codes or a dedicated authorization exception class +- Include assembly ACL decisions in audit logging diff --git a/packages/apollo-collaboration-server/src/changes/changes.controller.spec.ts b/packages/apollo-collaboration-server/src/changes/changes.controller.spec.ts index 03c474760..b0fb36d0a 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.controller.spec.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.controller.spec.ts @@ -1,18 +1,9 @@ -import { Test, type TestingModule } from '@nestjs/testing' - import { ChangesController } from './changes.controller.js' -import { ChangesService } from './changes.service.js' describe('ChangesController', () => { let controller: ChangesController - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [ChangesController], - providers: [ChangesService], - }).compile() - - controller = module.get(ChangesController) + beforeEach(() => { + controller = new ChangesController({} as never) }) it('should be defined', () => { diff --git a/packages/apollo-collaboration-server/src/changes/changes.controller.ts b/packages/apollo-collaboration-server/src/changes/changes.controller.ts index 902ada19e..2f9c493bd 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.controller.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.controller.ts @@ -38,8 +38,9 @@ export class ChangesController { } @Get() - async findAll(@Query() changeFilter: FindChangeDto) { + async findAll(@Query() changeFilter: FindChangeDto, @Req() request: Request) { + const { user } = request as unknown as { user: DecodedJWT } this.logger.debug(`ChangeFilter: ${JSON.stringify(changeFilter)}`) - return this.changesService.findAll(changeFilter) + return this.changesService.findAll(changeFilter, user) } } diff --git a/packages/apollo-collaboration-server/src/changes/changes.module.ts b/packages/apollo-collaboration-server/src/changes/changes.module.ts index 79a92bedb..c4e14f883 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.module.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.module.ts @@ -5,6 +5,7 @@ import { MongooseModule, getConnectionToken } from '@nestjs/mongoose' import idValidator from 'mongoose-id-validator' import { AssembliesModule } from '../assemblies/assemblies.module.js' +import { AssemblyPermissionsModule } from '../assemblyPermissions/assemblyPermissions.module.js' import { ChecksModule } from '../checks/checks.module.js' import { CountersModule } from '../counters/counters.module.js' import { FeaturesModule } from '../features/features.module.js' @@ -34,6 +35,7 @@ import { ChangesService } from './changes.service.js' }, ]), AssembliesModule, + AssemblyPermissionsModule, RefSeqsModule, RefSeqChunksModule, FeaturesModule, diff --git a/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts b/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts index 334bea9d3..d1462ef0c 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts @@ -1,19 +1,89 @@ -import { Test, type TestingModule } from '@nestjs/testing' +import { UnprocessableEntityException } from '@nestjs/common' import { ChangesService } from './changes.service.js' describe('ChangesService', () => { let service: ChangesService + const assemblyPermissionsService = { + canEdit: jest.fn(), + getViewableAssemblyIds: jest.fn(), + } + const changeModel = { + find: jest.fn(() => ({ + sort: jest.fn(() => ({ + exec: jest.fn().mockResolvedValue([]), + })), + })), + countDocuments: jest.fn(() => ({ exec: jest.fn().mockResolvedValue(0) })), + } + const countersService = { + getNextSequenceValue: jest.fn(), + } - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ChangesService], - }).compile() - - service = module.get(ChangesService) + beforeEach(() => { + jest.clearAllMocks() + service = new ChangesService( + {} as never, + {} as never, + {} as never, + changeModel as never, + {} as never, + // Slice 2 dependency + assemblyPermissionsService as never, + countersService as never, + {} as never, + {} as never, + ) }) it('should be defined', () => { expect(service).toBeDefined() }) + + it('denies non-admin assembly change when user lacks edit permission', async () => { + assemblyPermissionsService.canEdit.mockResolvedValueOnce(false) + const change = { + typeName: 'DeleteFeatureChange', + assembly: 'assembly123', + } + const user = { + id: 'user123', + username: 'user1', + email: 'user1@test', + role: 'user', + iat: 1, + exp: 2, + } + + await expect( + service.create(change as never, user as never), + ).rejects.toThrow(UnprocessableEntityException) + expect(assemblyPermissionsService.canEdit).toHaveBeenCalledWith( + 'user123', + 'assembly123', + ) + expect(countersService.getNextSequenceValue).not.toHaveBeenCalled() + }) + + it('findAll limits non-admin users to permitted assemblies', async () => { + assemblyPermissionsService.getViewableAssemblyIds.mockResolvedValueOnce([ + 'assembly123', + ]) + + await service.findAll({ typeName: 'DeleteFeatureChange' }, { + id: 'user123', + username: 'user1', + email: 'user1@test', + role: 'user', + iat: 1, + exp: 2, + } as never) + + expect(changeModel.find).toHaveBeenCalledWith( + expect.objectContaining({ + assembly: { $in: ['assembly123'] }, + typeName: 'DeleteFeatureChange', + }), + ) + }) }) diff --git a/packages/apollo-collaboration-server/src/changes/changes.service.ts b/packages/apollo-collaboration-server/src/changes/changes.service.ts index e25df4f76..73d81c298 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.service.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.service.ts @@ -33,8 +33,10 @@ import { import { InjectModel } from '@nestjs/mongoose' import { type FilterQuery, Model, Types } from 'mongoose' +import { AssemblyPermissionsService } from '../assemblyPermissions/assemblyPermissions.service.js' import { CountersService } from '../counters/counters.service.js' import { MessagesGateway } from '../messages/messages.gateway.js' +import { Role } from '../utils/role/role.enum.js' import { ChangeHandlersService } from './changeHandlers.service.js' import { FindChangeDto } from './dto/find-change.dto.js' @@ -57,6 +59,7 @@ export class ChangesService { private readonly refSeqChunkModel: Model, @InjectModel(Change.name) private readonly changeModel: Model, + private readonly assemblyPermissionsService: AssemblyPermissionsService, private readonly countersService: CountersService, private readonly messagesGateway: MessagesGateway, private readonly changeHandlersService: ChangeHandlersService, @@ -67,6 +70,20 @@ export class ChangesService { async create(change: BaseChange, user: DecodedJWT) { this.logger.debug(`Requested change: ${JSON.stringify(change)}`) + // Slice 2: write operations on assembly-scoped changes require edit grant + // unless the caller is a global admin. + if (isAssemblySpecificChange(change) && user.role !== Role.Admin) { + const canEdit = await this.assemblyPermissionsService.canEdit( + user.id, + change.assembly, + ) + if (!canEdit) { + throw new UnprocessableEntityException( + `User '${user.username}' does not have edit permission for assembly '${change.assembly}'`, + ) + } + } + const sequence = await this.countersService.getNextSequenceValue('changeCounter') const uniqUserId = `${user.email}-${sequence}` // Same user can upload data from more than one client @@ -264,7 +281,7 @@ export class ChangesService { return changeDoc } - async findAll(changeFilter: FindChangeDto) { + async findAll(changeFilter: FindChangeDto, callingUser: DecodedJWT) { const { assembly, changedIds, @@ -283,6 +300,21 @@ export class ChangesService { } = changeFilter const queryCond: FilterQuery = {} + if (callingUser.role !== Role.Admin) { + const allowedAssemblyIds = + await this.assemblyPermissionsService.getViewableAssemblyIds( + callingUser.id, + ) + if (assembly) { + if (!allowedAssemblyIds.includes(assembly)) { + return { changes: [], totalCount: 0 } + } + queryCond.assembly = assembly + } else { + queryCond.assembly = { $in: allowedAssemblyIds } + } + } + if (assembly) { queryCond.assembly = assembly } diff --git a/packages/apollo-collaboration-server/src/features/features.controller.spec.ts b/packages/apollo-collaboration-server/src/features/features.controller.spec.ts index d447a8e90..6132e88d6 100644 --- a/packages/apollo-collaboration-server/src/features/features.controller.spec.ts +++ b/packages/apollo-collaboration-server/src/features/features.controller.spec.ts @@ -1,18 +1,10 @@ -import { Test, type TestingModule } from '@nestjs/testing' - import { FeaturesController } from './features.controller.js' -import { FeaturesService } from './features.service.js' describe('FeaturesController', () => { let controller: FeaturesController - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [FeaturesController], - providers: [FeaturesService], - }).compile() - - controller = module.get(FeaturesController) + beforeEach(() => { + controller = new FeaturesController({} as never) }) it('should be defined', () => { diff --git a/packages/apollo-collaboration-server/src/features/features.controller.ts b/packages/apollo-collaboration-server/src/features/features.controller.ts index 136153b93..2b992d7a8 100644 --- a/packages/apollo-collaboration-server/src/features/features.controller.ts +++ b/packages/apollo-collaboration-server/src/features/features.controller.ts @@ -7,7 +7,10 @@ import { ParseBoolPipe, Post, Query, + Req, } from '@nestjs/common' +import type { DecodedJWT } from '@apollo-annotation/shared' +import type { Request } from 'express' import type { FeatureIdsSearchDto, @@ -34,8 +37,12 @@ export class FeaturesController { * http://localhost:3999/features/searchFeatures?term=exonerate */ @Get('searchFeatures') - async searchFeatures(@Query() request: { term: string; assemblies: string }) { - return this.featuresService.searchFeatures(request) + async searchFeatures( + @Query() request: { term: string; assemblies: string }, + @Req() req: Request, + ) { + const { user } = req as unknown as { user: DecodedJWT } + return this.featuresService.searchFeatures(request, user) } /** @@ -45,36 +52,52 @@ export class FeaturesController { * or if search data was not found or in case of error throw exception */ @Get('getFeatures') - getFeaturesByRange(@Query() request: FeatureRangeSearchDto) { + getFeaturesByRange( + @Query() request: FeatureRangeSearchDto, + @Req() req: Request, + ) { this.logger.debug( `getFeatures endpoint: refSeq: ${request.refSeq}, start: ${request.start}, end: ${request.end}`, ) + const { user } = req as unknown as { user: DecodedJWT } - return this.featuresService.findByRange(request) + return this.featuresService.findByRange(request, user) } @Post('getByIds') - findByFeatureIds(@Body() request: FeatureIdsSearchDto) { + findByFeatureIds(@Body() request: FeatureIdsSearchDto, @Req() req: Request) { this.logger.debug(`: featureIds: ${JSON.stringify(request.featureIds)}`) + const { user } = req as unknown as { user: DecodedJWT } return this.featuresService.findByFeatureIds( request.featureIds, request.topLevel, + user, ) } @Get('count') - async getFeatureCount(@Query() featureCountRequest: FeatureCountRequest) { + async getFeatureCount( + @Query() featureCountRequest: FeatureCountRequest, + @Req() req: Request, + ) { this.logger.debug( `Get features count by ${JSON.stringify(featureCountRequest)}`, ) - const count = - await this.featuresService.getFeatureCount(featureCountRequest) + const { user } = req as unknown as { user: DecodedJWT } + const count = await this.featuresService.getFeatureCount( + featureCountRequest, + user, + ) return { count } } @Get('getByIndexedId') - async getById(@Query() getByIndexedIdRequest: GetByIndexedIdRequest) { - return this.featuresService.getByIndexedId(getByIndexedIdRequest) + async getById( + @Query() getByIndexedIdRequest: GetByIndexedIdRequest, + @Req() req: Request, + ) { + const { user } = req as unknown as { user: DecodedJWT } + return this.featuresService.getByIndexedId(getByIndexedIdRequest, user) } /** @@ -88,9 +111,11 @@ export class FeaturesController { @Param('featureid') featureid: string, @Query('topLevel', new ParseBoolPipe({ optional: true })) topLevel: boolean | undefined, + @Req() req: Request, ) { this.logger.debug(`Get feature by featureId: ${featureid}`) - return this.featuresService.findById(featureid, topLevel) + const { user } = req as unknown as { user: DecodedJWT } + return this.featuresService.findById(featureid, topLevel, user) } @Get('check/:featureid') @@ -104,8 +129,9 @@ export class FeaturesController { * or if search data was not found or in case of error throw exception */ @Get() - getAll() { + getAll(@Req() req: Request) { this.logger.debug('Get all features') - return this.featuresService.findAll() + const { user } = req as unknown as { user: DecodedJWT } + return this.featuresService.findAll(user) } } diff --git a/packages/apollo-collaboration-server/src/features/features.module.ts b/packages/apollo-collaboration-server/src/features/features.module.ts index e9b5505e6..c2bf8db06 100644 --- a/packages/apollo-collaboration-server/src/features/features.module.ts +++ b/packages/apollo-collaboration-server/src/features/features.module.ts @@ -8,6 +8,7 @@ import { MongooseModule, getConnectionToken } from '@nestjs/mongoose' import type { Connection } from 'mongoose' import idValidator from 'mongoose-id-validator' +import { AssemblyPermissionsModule } from '../assemblyPermissions/assemblyPermissions.module.js' import { ChecksModule } from '../checks/checks.module.js' import { ChecksService } from '../checks/checks.service.js' import { RefSeqsModule } from '../refSeqs/refSeqs.module.js' @@ -21,6 +22,7 @@ import { FeaturesService } from './features.service.js' providers: [FeaturesService], imports: [ ChecksModule, + AssemblyPermissionsModule, RefSeqsModule, SequenceModule, MongooseModule.forFeatureAsync([ diff --git a/packages/apollo-collaboration-server/src/features/features.service.spec.ts b/packages/apollo-collaboration-server/src/features/features.service.spec.ts index 4349c3747..72904482d 100644 --- a/packages/apollo-collaboration-server/src/features/features.service.spec.ts +++ b/packages/apollo-collaboration-server/src/features/features.service.spec.ts @@ -1,16 +1,15 @@ -import { Test, type TestingModule } from '@nestjs/testing' - import { FeaturesService } from './features.service.js' describe('FeaturesService', () => { let service: FeaturesService - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [FeaturesService], - }).compile() - - service = module.get(FeaturesService) + beforeEach(() => { + service = new FeaturesService( + {} as never, + {} as never, + {} as never, + {} as never, + ) }) it('should be defined', () => { diff --git a/packages/apollo-collaboration-server/src/features/features.service.ts b/packages/apollo-collaboration-server/src/features/features.service.ts index 0661abe44..c889dab92 100644 --- a/packages/apollo-collaboration-server/src/features/features.service.ts +++ b/packages/apollo-collaboration-server/src/features/features.service.ts @@ -5,12 +5,20 @@ import { RefSeq, type RefSeqDocument, } from '@apollo-annotation/schemas' -import { Injectable, Logger, NotFoundException } from '@nestjs/common' +import type { DecodedJWT } from '@apollo-annotation/shared' +import { + Injectable, + Logger, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' +import { AssemblyPermissionsService } from '../assemblyPermissions/assemblyPermissions.service.js' import { ChecksService } from '../checks/checks.service.js' import type { FeatureRangeSearchDto } from '../entity/gff3Object.dto.js' +import { Role } from '../utils/role/role.enum.js' import type { FeatureCountRequest, @@ -20,6 +28,7 @@ import type { @Injectable() export class FeaturesService { constructor( + private readonly assemblyPermissionsService: AssemblyPermissionsService, private readonly checksService: ChecksService, @InjectModel(Feature.name) private readonly featureModel: Model, @@ -29,11 +38,59 @@ export class FeaturesService { private readonly logger = new Logger(FeaturesService.name) - findAll() { - return this.featureModel.find().exec() + private isAdmin(user: DecodedJWT) { + return user.role === Role.Admin + } + + private async ensureCanViewAssembly(user: DecodedJWT, assemblyId: string) { + if (this.isAdmin(user)) { + return + } + const canView = await this.assemblyPermissionsService.canView( + user.id, + assemblyId, + ) + if (!canView) { + throw new UnprocessableEntityException( + `User '${user.username}' does not have view permission for assembly '${assemblyId}'`, + ) + } } - async getFeatureCount(featureCountRequest: FeatureCountRequest) { + private async getAllowedAssemblyIds( + user: DecodedJWT, + requestedAssemblyIds?: string[], + ): Promise { + if (this.isAdmin(user)) { + return requestedAssemblyIds ?? [] + } + const allowed = + await this.assemblyPermissionsService.getViewableAssemblyIds(user.id) + if (!requestedAssemblyIds) { + return allowed + } + return requestedAssemblyIds.filter((assemblyId) => + allowed.includes(assemblyId), + ) + } + + async findAll(user: DecodedJWT) { + const allowedAssemblyIds = await this.getAllowedAssemblyIds(user) + if (!this.isAdmin(user) && allowedAssemblyIds.length === 0) { + return [] + } + const refSeqs = await this.refSeqModel + .find(this.isAdmin(user) ? {} : { assembly: { $in: allowedAssemblyIds } }) + .select('_id') + .exec() + const refSeqIds = refSeqs.map((refSeq) => refSeq._id) + return this.featureModel.find({ refSeq: { $in: refSeqIds } }).exec() + } + + async getFeatureCount( + featureCountRequest: FeatureCountRequest, + user: DecodedJWT, + ) { let count = 0 const { assemblyId, end, refSeqId, start } = featureCountRequest const filter: Record< @@ -49,9 +106,15 @@ export class FeaturesService { } if (refSeqId) { + const refSeqDoc = await this.refSeqModel.findById(refSeqId).exec() + if (!refSeqDoc) { + throw new NotFoundException(`RefSeq with id '${refSeqId}' not found`) + } + await this.ensureCanViewAssembly(user, refSeqDoc.assembly.toString()) filter.refSeq = refSeqId count = await this.featureModel.countDocuments(filter) } else if (assemblyId) { + await this.ensureCanViewAssembly(user, assemblyId) const refSeqs: RefSeqDocument[] = await this.refSeqModel .find({ assembly: assemblyId }) .exec() @@ -61,26 +124,53 @@ export class FeaturesService { count += await this.featureModel.countDocuments(filter) } } else { - // returns count of all documents or in the range (start, end) - count = await this.featureModel.countDocuments(filter) + if (this.isAdmin(user)) { + count = await this.featureModel.countDocuments(filter) + } else { + const allowedAssemblyIds = await this.getAllowedAssemblyIds(user) + if (allowedAssemblyIds.length === 0) { + return 0 + } + const refSeqs: RefSeqDocument[] = await this.refSeqModel + .find({ assembly: { $in: allowedAssemblyIds } }) + .exec() + for (const refSeq of refSeqs) { + filter.refSeq = refSeq._id.toString() + count += await this.featureModel.countDocuments(filter) + } + } } this.logger.debug(`Number of features is ${count}`) return count } - async getByIndexedId(getByIndexedIdRequest: GetByIndexedIdRequest) { + async getByIndexedId( + getByIndexedIdRequest: GetByIndexedIdRequest, + user: DecodedJWT, + ) { const { assemblies, id, topLevel } = getByIndexedIdRequest - const refSeqsQuery: { refSeq?: RefSeqDocument[] } = {} - if (assemblies) { - const assemblyIds = assemblies.split(',') - const refSeqs = await this.refSeqModel - .find({ assembly: assemblyIds }) - .exec() - refSeqsQuery.refSeq = refSeqs + const requestedAssemblyIds = assemblies ? assemblies.split(',') : undefined + const allowedAssemblyIds = await this.getAllowedAssemblyIds( + user, + requestedAssemblyIds, + ) + if (!this.isAdmin(user) && allowedAssemblyIds.length === 0) { + return [] } + + const refSeqs = await this.refSeqModel + .find( + requestedAssemblyIds || !this.isAdmin(user) + ? { assembly: { $in: allowedAssemblyIds } } + : {}, + ) + .select('_id') + .exec() + const refSeqIds = refSeqs.map((refSeq) => refSeq._id) + const topLevelFeatures = await this.featureModel - .find({ indexedIds: id, ...refSeqsQuery }) + .find({ indexedIds: id, refSeq: { $in: refSeqIds } }) .exec() if (topLevelFeatures.length === 0) { return [] @@ -116,7 +206,11 @@ export class FeaturesService { return } - async findByFeatureIds(featureIds: string[], topLevel?: boolean) { + async findByFeatureIds( + featureIds: string[], + topLevel?: boolean, + user?: DecodedJWT, + ) { const foundFeatures: Feature[] = [] // all featureIds that have already been fetched const fetchedFeatureIds = new Set() @@ -128,7 +222,7 @@ export class FeaturesService { } try { - const feature = await this.findById(featureId, topLevel) + const feature = await this.findById(featureId, topLevel, user) foundFeatures.push(feature) for (const id of feature.allIds) { fetchedFeatureIds.add(id) @@ -149,7 +243,7 @@ export class FeaturesService { * @param topLevel - If true, return the top level feature and its children. If false, return the requested feature and its children. * @returns Return the feature(s) if search was successful. Otherwise throw exception */ - async findById(featureId: string, topLevel?: boolean) { + async findById(featureId: string, topLevel?: boolean, user?: DecodedJWT) { // Search correct feature const topLevelFeature = await this.featureModel .findOne({ allIds: featureId }) @@ -161,6 +255,18 @@ export class FeaturesService { throw new NotFoundException(errMsg) } + if (user) { + const refSeqDoc = await this.refSeqModel + .findById(topLevelFeature.refSeq) + .exec() + if (!refSeqDoc) { + throw new NotFoundException( + `RefSeq for feature '${featureId}' was not found`, + ) + } + await this.ensureCanViewAssembly(user, refSeqDoc.assembly.toString()) + } + // Now we need to find correct top level feature or sub-feature inside the feature const foundFeature = this.getFeatureFromId( topLevelFeature, @@ -221,7 +327,17 @@ export class FeaturesService { return null } - async findByRange(searchDto: FeatureRangeSearchDto) { + async findByRange(searchDto: FeatureRangeSearchDto, user?: DecodedJWT) { + const refSeqDoc = await this.refSeqModel.findById(searchDto.refSeq).exec() + if (!refSeqDoc) { + throw new NotFoundException( + `RefSeq with id '${searchDto.refSeq}' not found`, + ) + } + if (user) { + await this.ensureCanViewAssembly(user, refSeqDoc.assembly.toString()) + } + const featureDocs = await this.featureModel .find({ refSeq: searchDto.refSeq, @@ -245,14 +361,26 @@ export class FeaturesService { return this.checksService.checkFeature(topLevelFeature, checkTimestamps) } - async searchFeatures(searchDto: { term: string; assemblies: string }) { + async searchFeatures( + searchDto: { term: string; assemblies: string }, + user: DecodedJWT, + ) { const { assemblies, term } = searchDto - const assemblyIds = assemblies.split(',') + const requestedAssemblyIds = assemblies.split(',') + const assemblyIds = await this.getAllowedAssemblyIds( + user, + requestedAssemblyIds, + ) + if (!this.isAdmin(user) && assemblyIds.length === 0) { + return [] + } const refSeqs = await this.refSeqModel .find({ assembly: assemblyIds }) + .select('_id') .exec() + const refSeqIds = refSeqs.map((refSeq) => refSeq._id) return this.featureModel - .find({ $text: { $search: `"${term}"` }, refSeq: refSeqs }) + .find({ $text: { $search: `"${term}"` }, refSeq: { $in: refSeqIds } }) .exec() } } diff --git a/packages/apollo-schemas/src/assemblyPermission.schema.ts b/packages/apollo-schemas/src/assemblyPermission.schema.ts new file mode 100644 index 000000000..46bf5f290 --- /dev/null +++ b/packages/apollo-schemas/src/assemblyPermission.schema.ts @@ -0,0 +1,51 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { + type HydratedDocument, + Schema as MongooseSchema, + type Types, +} from 'mongoose' + +export type AssemblyPermissionDocument = HydratedDocument + +@Schema({ timestamps: true }) +export class AssemblyPermission { + @Prop({ + type: MongooseSchema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }) + userId: Types.ObjectId + + @Prop({ + type: MongooseSchema.Types.ObjectId, + ref: 'Assembly', + required: true, + index: true, + }) + assemblyId: Types.ObjectId + + @Prop({ required: true, default: false }) + canViewAnnotations: boolean + + @Prop({ required: true, default: false }) + canEditAnnotations: boolean + + @Prop() + createdBy?: string + + @Prop() + updatedBy?: string +} + +export const AssemblyPermissionSchema = + SchemaFactory.createForClass(AssemblyPermission) + +AssemblyPermissionSchema.index({ userId: 1, assemblyId: 1 }, { unique: true }) + +AssemblyPermissionSchema.pre('validate', function onValidate(next) { + if (this.canEditAnnotations) { + this.canViewAnnotations = true + } + next() +}) diff --git a/packages/apollo-schemas/src/index.ts b/packages/apollo-schemas/src/index.ts index 139d9606f..c2b4cbd85 100644 --- a/packages/apollo-schemas/src/index.ts +++ b/packages/apollo-schemas/src/index.ts @@ -1,4 +1,5 @@ export * from './assembly.schema.js' +export * from './assemblyPermission.schema.js' export * from './change.schema.js' export * from './check.schema.js' export * from './checkResult.schema.js' diff --git a/packages/jbrowse-plugin-apollo/src/components/MANAGE_USERS_ASSEMBLY_ACL_NOTES.md b/packages/jbrowse-plugin-apollo/src/components/MANAGE_USERS_ASSEMBLY_ACL_NOTES.md new file mode 100644 index 000000000..c9c4fd5eb --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/MANAGE_USERS_ASSEMBLY_ACL_NOTES.md @@ -0,0 +1,88 @@ +# Manage Users Assembly ACL Notes + +## Purpose + +This document explains the assembly-permission UI that was added to the Manage +Users dialog. + +## Where the logic lives + +- `components/ManageUsers.tsx` +- `components/manageUsersAssemblyPermissions.ts` + +The dialog now has two admin panels: + +- User role management (existing behavior) +- Assembly permission management (new behavior) + +## API usage + +The component calls collaboration server endpoints via the selected Apollo +internet account fetcher. + +Read paths: + +- `GET /users` +- `GET /assemblies` +- `GET /assemblyPermissions/byUser/:userId` + +Write path: + +- `PUT /assemblyPermissions/:userId/:assemblyId` + +## State model in ManageUsers + +- `users`: user list from `/users` +- `assemblies`: assembly list from `/assemblies` +- `selectedUserId`: currently managed user for assembly grants +- `assemblyPermissionsByAssemblyId`: local cache of grant documents keyed by + assembly id + +## Edit behavior + +Each assembly row has two editable booleans: + +- `canViewAnnotations` +- `canEditAnnotations` + +Invariant enforced in UI and backend: + +- `canEditAnnotations=true` implies `canViewAnnotations=true` + +When a row is edited, the component normalizes the payload before sending: + +- if edit is true, view is forced true + +Normalization helper: + +- `normalizeAssemblyPermissionUpdate()` + +Row-building helper: + +- `buildAssemblyPermissionRows()` + +Permission indexing helper: + +- `indexPermissionsByAssemblyId()` + +## Error handling + +All request failures are converted into user-facing messages using +`createFetchErrorMessage` and shown at the bottom of the dialog. + +## Known follow-up + +Current implementation supports one-user-at-a-time grant edits. Bulk grant +actions and assembly filtering are intentionally deferred for a later slice. + +## Unit tests + +Helper-level tests for the new logic are in: + +- `components/manageUsersAssemblyPermissions.test.ts` + +The tests currently cover: + +- assembly permission indexing by assembly id +- edit-implies-view normalization +- assembly row construction defaults when no grant exists diff --git a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx index 6fe7788de..bea03b53a 100644 --- a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx @@ -3,19 +3,21 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import { DeleteUserChange, UserChange } from '@apollo-annotation/shared' import { getRoot } from '@jbrowse/mobx-state-tree' import DeleteIcon from '@mui/icons-material/Delete' import { + Box, Button, DialogActions, DialogContent, DialogContentText, + FormControl, + InputLabel, MenuItem, Select, type SelectChangeEvent, + Typography, } from '@mui/material' import { DataGrid, @@ -36,6 +38,16 @@ import { type ApolloRootModel, isApolloInternetAccount } from '../types' import { createFetchErrorMessage } from '../util' import { Dialog } from './Dialog' +import type { + AssemblyPermissionResponse, + AssemblyPermissionRow, + AssemblyResponse, +} from './manageUsersAssemblyPermissions' +import { + buildAssemblyPermissionRows, + indexPermissionsByAssemblyId, + normalizeAssemblyPermissionUpdate, +} from './manageUsersAssemblyPermissions' interface UserResponse { _id: string @@ -57,8 +69,11 @@ export function ManageUsers({ }: ManageUsersProps) { const { internetAccounts } = getRoot(session) const apolloInternetAccounts: ApolloInternetAccountModel[] = internetAccounts - .filter((ia) => isApolloInternetAccount(ia)) - .filter((ia) => ia.role?.includes('admin')) + .filter((ia: unknown) => isApolloInternetAccount(ia)) + .filter( + (ia: ApolloInternetAccountModel & { role?: string }) => + ia.role?.includes('admin') ?? false, + ) if (apolloInternetAccounts.length === 0) { throw new Error('No Apollo internet account found') } @@ -67,11 +82,70 @@ export function ManageUsers({ apolloInternetAccounts[0], ) const [users, setUsers] = useState([]) + const [assemblies, setAssemblies] = useState([]) + const [selectedUserId, setSelectedUserId] = useState('') + const [assemblyPermissionsByAssemblyId, setAssemblyPermissionsByAssemblyId] = + useState>>({}) useEffect(() => { async function getUsers() { const { baseURL } = selectedInternetAccount - const uri = new URL('users', baseURL).href + const usersUri = new URL('users', baseURL).href + const assembliesUri = new URL('assemblies', baseURL).href + const usersFetcher = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: usersUri, + }) + const assembliesFetcher = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: assembliesUri, + }) + const [usersResponse, assembliesResponse] = await Promise.all([ + usersFetcher(usersUri, { method: 'GET' }), + assembliesFetcher(assembliesUri, { method: 'GET' }), + ]) + if (!usersResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + usersResponse, + 'Error when getting user data from db', + ) + throw new Error(newErrorMessage) + } + if (!assembliesResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + assembliesResponse, + 'Error when getting assemblies from db', + ) + throw new Error(newErrorMessage) + } + const userData = (await usersResponse.json()) as UserResponse[] + const assemblyData = + (await assembliesResponse.json()) as AssemblyResponse[] + const normalizedUsers = userData.map((u) => + u.role === undefined ? { ...u, role: '' } : u, + ) + setUsers(normalizedUsers) + setAssemblies(assemblyData) + if (normalizedUsers[0]?._id) { + setSelectedUserId(normalizedUsers[0]._id) + } + } + getUsers().catch((error) => { + setErrorMessage(String(error)) + }) + }, [selectedInternetAccount]) + + useEffect(() => { + async function getAssemblyPermissionsForUser() { + if (!selectedUserId) { + setAssemblyPermissionsByAssemblyId({}) + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/byUser/${selectedUserId}`, + baseURL, + ).href const apolloFetch = selectedInternetAccount.getFetcher({ locationType: 'UriLocation', uri, @@ -80,18 +154,20 @@ export function ManageUsers({ if (!response.ok) { const newErrorMessage = await createFetchErrorMessage( response, - 'Error when getting user data from db', + 'Error when getting assembly permissions from db', ) - setErrorMessage(newErrorMessage) - return + throw new Error(newErrorMessage) } - const data = (await response.json()) as UserResponse[] - setUsers(data.map((u) => (u.role === undefined ? { ...u, role: '' } : u))) + const permissionData = + (await response.json()) as AssemblyPermissionResponse[] + setAssemblyPermissionsByAssemblyId( + indexPermissionsByAssemblyId(permissionData), + ) } - getUsers().catch((error) => { + getAssemblyPermissionsForUser().catch((error) => { setErrorMessage(String(error)) }) - }, [selectedInternetAccount]) + }, [selectedInternetAccount, selectedUserId]) async function deleteUser(id: GridRowId) { const change = new DeleteUserChange({ @@ -101,7 +177,9 @@ export function ManageUsers({ await changeManager.submit(change, { internetAccountId: selectedInternetAccount.internetAccountId, }) - setUsers((prevUsers) => prevUsers.filter((row) => row._id !== id)) + setUsers((prevUsers: UserResponse[]) => + prevUsers.filter((row: UserResponse) => row._id !== id), + ) } function isCurrentUser(id: GridRowId) { @@ -120,7 +198,7 @@ export function ManageUsers({ width: 140, type: 'singleSelect', valueOptions: ['readOnly', 'user', 'admin', 'none'], - getOptionLabel(value) { + getOptionLabel(value: string) { switch (value) { case 'readOnly': { return 'Read-only' @@ -162,7 +240,9 @@ export function ManageUsers({ function handleChangeInternetAccount(e: SelectChangeEvent) { const newlySelectedInternetAccount = apolloInternetAccounts.find( - (ia) => ia.internetAccountId === e.target.value, + (ia) => + (ia as ApolloInternetAccountModel & { internetAccountId: string }) + .internetAccountId === e.target.value, ) if (!newlySelectedInternetAccount) { throw new Error( @@ -172,6 +252,10 @@ export function ManageUsers({ setSelectedInternetAccount(newlySelectedInternetAccount) } + function handleChangeManagedUser(e: SelectChangeEvent) { + setSelectedUserId(e.target.value) + } + async function processRowUpdate(newRow: GridRowModel) { const change = new UserChange({ typeName: 'UserChange', @@ -184,6 +268,90 @@ export function ManageUsers({ return newRow } + async function processAssemblyPermissionRowUpdate(newRow: GridRowModel) { + if (!selectedUserId) { + return newRow + } + const { canViewAnnotations, canEditAnnotations } = + normalizeAssemblyPermissionUpdate({ + canViewAnnotations: Boolean(newRow.canViewAnnotations), + canEditAnnotations: Boolean(newRow.canEditAnnotations), + }) + + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/${selectedUserId}/${newRow.assemblyId as string}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + canViewAnnotations, + canEditAnnotations, + }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating assembly permission', + ) + throw new Error(newErrorMessage) + } + + const savedPermission = + (await response.json()) as AssemblyPermissionResponse + setAssemblyPermissionsByAssemblyId( + (prev: Partial>) => ({ + ...prev, + [savedPermission.assemblyId]: savedPermission, + }), + ) + + return { + ...newRow, + canViewAnnotations, + canEditAnnotations, + } + } + + const selectedManagedUser = users.find( + (user: UserResponse) => user._id === selectedUserId, + ) + + const assemblyPermissionRows: AssemblyPermissionRow[] = + buildAssemblyPermissionRows(assemblies, assemblyPermissionsByAssemblyId) + + const assemblyPermissionColumns: GridColDef[] = [ + { + field: 'assemblyName', + headerName: 'Assembly', + width: 280, + editable: false, + }, + { + field: 'canViewAnnotations', + headerName: 'Can view annotations', + width: 210, + type: 'boolean', + editable: true, + }, + { + field: 'canEditAnnotations', + headerName: 'Can edit annotations', + width: 210, + type: 'boolean', + editable: true, + }, + ] + return ( - {internetAccounts.map((option) => ( - - {option.name} - - ))} + {internetAccounts.map( + ( + option: ApolloInternetAccountModel & { + id: string + internetAccountId: string + name: string + }, + ) => ( + + {option.name} + + ), + )} ) : null}
+ + User roles + row._id} + getRowId={(row: UserResponse) => row._id} slots={{ toolbar: GridToolbar }} getRowHeight={() => 'auto'} isCellEditable={(params: GridCellParams) => !isCurrentUser(params.id) } processRowUpdate={processRowUpdate} - onProcessRowUpdateError={(error) => { + onProcessRowUpdateError={(error: unknown) => { setErrorMessage(String(error)) }} />
+ + + + Assembly permissions + + + Managed user + + + {selectedManagedUser ? ( + + {`Managing assembly access for ${selectedManagedUser.username}`} + + ) : null} +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + /> +
+
+ + + ) : null} {allowGuest ? ( <> diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts index 8ee1025e2..0804fcd65 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts @@ -42,6 +42,12 @@ import { AuthTypeSelector } from './components/AuthTypeSelector' import type { ApolloInternetAccountConfigModel } from './configSchema' type AuthType = 'google' | 'microsoft' | 'logingov' | 'guest' +type LocalAuthSelection = { + type: 'local' + identifier: string + password: string +} +type AuthSelection = AuthType | LocalAuthSelection type Role = 'admin' | 'user' | 'readOnly' | 'none' @@ -231,7 +237,7 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { ): Promise { const { baseURL } = self const authType = await new Promise( - (resolve: (authType: AuthType) => void, reject) => { + (resolve: (authType: AuthSelection) => void, reject) => { const { session } = getRoot(self) const { baseURL, name } = self ;(session as unknown as AbstractSessionModel).queueDialog( @@ -239,7 +245,7 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { AuthTypeSelector, { name, - handleClose: (newAuthType?: AuthType | Error) => { + handleClose: (newAuthType?: AuthSelection | Error) => { if (!newAuthType) { reject(new Error('user cancelled entry')) } else if (newAuthType instanceof Error) { @@ -255,13 +261,38 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { ) }, ) - if (authType !== 'guest') { + if (typeof authType !== 'string' && authType.type === 'local') { + const uri = new URL('auth/local', baseURL).href + const response = await fetch(uri, { + method: 'POST', + signal: self.controller.signal, + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + identifier: authType.identifier, + password: authType.password, + }), + }) + if (!response.ok) { + const errorMessage = await createFetchErrorMessage( + response, + 'Error when logging in locally', + ) + reject(new Error(errorMessage)) + return + } + const { token } = await response.json() + resolve(token) + return + } + if (typeof authType === 'string' && authType !== 'guest') { // eslint-disable-next-line @typescript-eslint/no-floating-promises self.openAuthWindow(authType, resolve, reject) return } const url = new URL('auth/login', baseURL) - const searchParams = new URLSearchParams({ type: authType }) + const searchParams = new URLSearchParams({ type: 'guest' }) url.search = searchParams.toString() const uri = url.toString() const response = await fetch(uri, { signal: self.controller.signal }) diff --git a/packages/jbrowse-plugin-apollo/src/components/AddAssemblyAliases.tsx b/packages/jbrowse-plugin-apollo/src/components/AddAssemblyAliases.tsx index a52201a29..de6e1f670 100644 --- a/packages/jbrowse-plugin-apollo/src/components/AddAssemblyAliases.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/AddAssemblyAliases.tsx @@ -11,6 +11,7 @@ import type { ChangeManager } from '../ChangeManager' import type { ApolloSessionModel } from '../session' import { Dialog } from './Dialog' +import { apolloDataGridSx } from './dataGridStyles' interface AddAssemblyAliasProps { session: ApolloSessionModel @@ -91,6 +92,7 @@ export function AddAssemblyAliases({ (session) const apolloInternetAccounts: ApolloInternetAccountModel[] = internetAccounts - .filter((ia: unknown) => isApolloInternetAccount(ia)) + .filter(isApolloInternetAccount) .filter( (ia: ApolloInternetAccountModel & { role?: string }) => ia.role?.includes('admin') ?? false, @@ -83,15 +148,41 @@ export function ManageUsers({ ) const [users, setUsers] = useState([]) const [assemblies, setAssemblies] = useState([]) + const [groups, setGroups] = useState([]) + const [groupFilterText, setGroupFilterText] = useState('') const [selectedUserId, setSelectedUserId] = useState('') + const [selectedGroupId, setSelectedGroupId] = useState('') + const [permissionView, setPermissionView] = + useState('effective') + const [groupMembershipView, setGroupMembershipView] = + useState('edit') + const [groupPermissionView, setGroupPermissionView] = + useState('current') const [assemblyPermissionsByAssemblyId, setAssemblyPermissionsByAssemblyId] = useState>>({}) + const [ + effectiveAssemblyPermissionsByAssemblyId, + setEffectiveAssemblyPermissionsByAssemblyId, + ] = useState>>({}) + const [groupMembershipByUserId, setGroupMembershipByUserId] = useState< + Partial> + >({}) + const [groupPermissionsByAssemblyId, setGroupPermissionsByAssemblyId] = + useState>>({}) + const [newGroupName, setNewGroupName] = useState('') + const [newGroupDescription, setNewGroupDescription] = useState('') + const [localUserDialogOpen, setLocalUserDialogOpen] = useState(false) + const [newLocalUsername, setNewLocalUsername] = useState('') + const [newLocalEmail, setNewLocalEmail] = useState('') + const [newLocalPassword, setNewLocalPassword] = useState('') + const [newLocalRole, setNewLocalRole] = useState('user') useEffect(() => { async function getUsers() { const { baseURL } = selectedInternetAccount const usersUri = new URL('users', baseURL).href const assembliesUri = new URL('assemblies', baseURL).href + const groupsUri = new URL('assemblyPermissions/groups', baseURL).href const usersFetcher = selectedInternetAccount.getFetcher({ locationType: 'UriLocation', uri: usersUri, @@ -100,10 +191,16 @@ export function ManageUsers({ locationType: 'UriLocation', uri: assembliesUri, }) - const [usersResponse, assembliesResponse] = await Promise.all([ - usersFetcher(usersUri, { method: 'GET' }), - assembliesFetcher(assembliesUri, { method: 'GET' }), - ]) + const groupsFetcher = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: groupsUri, + }) + const [usersResponse, assembliesResponse, groupsResponse] = + await Promise.all([ + usersFetcher(usersUri, { method: 'GET' }), + assembliesFetcher(assembliesUri, { method: 'GET' }), + groupsFetcher(groupsUri, { method: 'GET' }), + ]) if (!usersResponse.ok) { const newErrorMessage = await createFetchErrorMessage( usersResponse, @@ -118,17 +215,32 @@ export function ManageUsers({ ) throw new Error(newErrorMessage) } + if (!groupsResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + groupsResponse, + 'Error when getting groups from db', + ) + throw new Error(newErrorMessage) + } const userData = (await usersResponse.json()) as UserResponse[] const assemblyData = (await assembliesResponse.json()) as AssemblyResponse[] - const normalizedUsers = userData.map((u) => - u.role === undefined ? { ...u, role: '' } : u, - ) + const groupData = (await groupsResponse.json()) as GroupResponse[] + const normalizedUsers: UserResponse[] = userData.map((u) => ({ + ...u, + role: u.role ?? '', + })) setUsers(normalizedUsers) setAssemblies(assemblyData) + setGroups(groupData.sort((a, b) => a.name.localeCompare(b.name))) if (normalizedUsers[0]?._id) { setSelectedUserId(normalizedUsers[0]._id) } + if (groupData[0]?._id) { + setSelectedGroupId(groupData[0]._id) + } else { + setSelectedGroupId('') + } } getUsers().catch((error) => { setErrorMessage(String(error)) @@ -169,6 +281,118 @@ export function ManageUsers({ }) }, [selectedInternetAccount, selectedUserId]) + useEffect(() => { + async function getEffectiveAssemblyPermissionsForUser() { + if (!selectedUserId) { + setEffectiveAssemblyPermissionsByAssemblyId({}) + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/effective/byUser/${selectedUserId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when getting effective assembly permissions from db', + ) + throw new Error(newErrorMessage) + } + const permissionData = + (await response.json()) as EffectiveAssemblyPermissionResponse[] + const byAssemblyId: Partial< + Record + > = {} + for (const permission of permissionData) { + byAssemblyId[permission.assemblyId] = permission + } + setEffectiveAssemblyPermissionsByAssemblyId(byAssemblyId) + } + getEffectiveAssemblyPermissionsForUser().catch((error) => { + setErrorMessage(String(error)) + }) + }, [selectedInternetAccount, selectedUserId]) + + useEffect(() => { + async function getMembershipsForGroup() { + if (!selectedGroupId) { + setGroupMembershipByUserId({}) + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/memberships/byGroup/${selectedGroupId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when getting group memberships from db', + ) + throw new Error(newErrorMessage) + } + const membershipData = + (await response.json()) as GroupMembershipResponse[] + const next: Partial> = {} + for (const membership of membershipData) { + next[membership.userId] = true + } + setGroupMembershipByUserId(next) + } + getMembershipsForGroup().catch((error) => { + setErrorMessage(String(error)) + }) + }, [selectedGroupId, selectedInternetAccount]) + + useEffect(() => { + async function getAssemblyPermissionsForGroup() { + if (!selectedGroupId) { + setGroupPermissionsByAssemblyId({}) + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/permissions/${selectedGroupId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when getting group assembly permissions from db', + ) + throw new Error(newErrorMessage) + } + const permissionData = + (await response.json()) as GroupAssemblyPermissionResponse[] + const byAssemblyId: Partial< + Record + > = {} + for (const permission of permissionData) { + byAssemblyId[permission.assemblyId] = permission + } + setGroupPermissionsByAssemblyId(byAssemblyId) + } + getAssemblyPermissionsForGroup().catch((error) => { + setErrorMessage(String(error)) + }) + }, [selectedGroupId, selectedInternetAccount]) + async function deleteUser(id: GridRowId) { const change = new DeleteUserChange({ typeName: 'DeleteUserChange', @@ -182,6 +406,44 @@ export function ManageUsers({ ) } + async function createLocalUser() { + const { baseURL } = selectedInternetAccount + const uri = new URL('users/local', baseURL).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username: newLocalUsername, + email: newLocalEmail, + password: newLocalPassword, + role: newLocalRole === 'none' ? undefined : newLocalRole, + }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when creating local user', + ) + throw new Error(newErrorMessage) + } + + const createdUser = (await response.json()) as UserResponse + setUsers((prevUsers: UserResponse[]) => [...prevUsers, createdUser]) + setSelectedUserId(createdUser._id) + setLocalUserDialogOpen(false) + setNewLocalUsername('') + setNewLocalEmail('') + setNewLocalPassword('') + setNewLocalRole('user') + } + function isCurrentUser(id: GridRowId) { if (id === selectedInternetAccount.getUserId()) { return true @@ -199,7 +461,8 @@ export function ManageUsers({ type: 'singleSelect', valueOptions: ['readOnly', 'user', 'admin', 'none'], getOptionLabel(value: string) { - switch (value) { + const option = String(value) + switch (option) { case 'readOnly': { return 'Read-only' } @@ -256,6 +519,10 @@ export function ManageUsers({ setSelectedUserId(e.target.value) } + function handleChangeManagedGroup(e: SelectChangeEvent) { + setSelectedGroupId(e.target.value) + } + async function processRowUpdate(newRow: GridRowModel) { const change = new UserChange({ typeName: 'UserChange', @@ -322,13 +589,367 @@ export function ManageUsers({ } } + async function createGroup() { + if (!newGroupName.trim()) { + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL('assemblyPermissions/groups', baseURL).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: newGroupName.trim(), + description: newGroupDescription.trim() || undefined, + }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when creating group', + ) + throw new Error(newErrorMessage) + } + + const createdGroup = (await response.json()) as GroupResponse + setGroups((prevGroups: GroupResponse[]) => + [...prevGroups, createdGroup].sort((a, b) => + a.name.localeCompare(b.name), + ), + ) + setSelectedGroupId(createdGroup._id) + setNewGroupName('') + setNewGroupDescription('') + } + + async function deleteSelectedGroup() { + if (!selectedGroupId) { + return + } + const groupToDelete = groups.find( + (group: GroupResponse) => group._id === selectedGroupId, + ) + if (!groupToDelete) { + return + } + if (!globalThis.confirm(`Delete group "${groupToDelete.name}"?`)) { + return + } + + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/${selectedGroupId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'DELETE' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when deleting group', + ) + throw new Error(newErrorMessage) + } + + const remainingGroups = groups.filter( + (group: GroupResponse) => group._id !== selectedGroupId, + ) + setGroups(remainingGroups) + setSelectedGroupId(remainingGroups[0]?._id ?? '') + } + + async function processGroupMembershipRowUpdate(newRow: GridRowModel) { + if (!selectedGroupId) { + return newRow + } + const userId = String(newRow.userId) + const isMember = Boolean(newRow.isMember) + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/memberships/${selectedGroupId}/${userId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ isMember }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating group membership', + ) + throw new Error(newErrorMessage) + } + + setGroupMembershipByUserId((prev: Partial>) => ({ + ...prev, + [userId]: isMember, + })) + + return { + ...newRow, + isMember, + } + } + + async function processGroupAssemblyPermissionRowUpdate(newRow: GridRowModel) { + if (!selectedGroupId) { + return newRow + } + const { canViewAnnotations, canEditAnnotations } = + normalizeAssemblyPermissionUpdate({ + canViewAnnotations: Boolean(newRow.canViewAnnotations), + canEditAnnotations: Boolean(newRow.canEditAnnotations), + }) + + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/permissions/${selectedGroupId}/${newRow.assemblyId as string}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + canViewAnnotations, + canEditAnnotations, + }), + }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating group assembly permission', + ) + throw new Error(newErrorMessage) + } + + const savedPermission = + (await response.json()) as GroupAssemblyPermissionResponse + setGroupPermissionsByAssemblyId( + (prev: Partial>) => ({ + ...prev, + [savedPermission.assemblyId]: savedPermission, + }), + ) + + return { + ...newRow, + canViewAnnotations, + canEditAnnotations, + } + } + + async function toggleAssemblyPermission( + row: AssemblyPermissionRow, + field: 'canViewAnnotations' | 'canEditAnnotations', + checked: boolean, + ) { + await processAssemblyPermissionRowUpdate({ + ...row, + [field]: checked, + }) + } + + async function toggleGroupMembership( + row: GroupMembershipRow, + checked: boolean, + ) { + await processGroupMembershipRowUpdate({ + ...row, + isMember: checked, + }) + } + + async function toggleGroupAssemblyPermission( + row: AssemblyPermissionRow, + field: 'canViewAnnotations' | 'canEditAnnotations', + checked: boolean, + ) { + await processGroupAssemblyPermissionRowUpdate({ + ...row, + [field]: checked, + }) + } + + function buildTogglePermissionColumn( + field: 'canViewAnnotations' | 'canEditAnnotations', + headerName: string, + onToggle: (row: AssemblyPermissionRow, checked: boolean) => Promise, + disabled: (params: GridRenderCellParams) => boolean, + ): GridColDef { + return { + field, + headerName, + width: 210, + sortable: false, + editable: false, + renderCell: (params: GridRenderCellParams) => ( + ) => { + event.stopPropagation() + }} + onChange={( + _event: React.ChangeEvent, + checked: boolean, + ) => { + onToggle(params.row, checked).catch((error) => { + setErrorMessage(String(error)) + }) + }} + /> + ), + } + } + + function buildEffectivePermissionColumn( + field: 'canViewAnnotations' | 'canEditAnnotations', + headerName: string, + ): GridColDef { + return { + field, + headerName, + width: 170, + sortable: false, + editable: false, + renderCell: ( + params: GridRenderCellParams, + ) => { + const enabled = Boolean(params.row[field]) + return ( + : } + label={enabled ? 'Allowed' : 'No'} + color={enabled ? 'success' : 'default'} + variant={enabled ? 'filled' : 'outlined'} + sx={{ + minWidth: 98, + fontWeight: 700, + '& .MuiChip-icon': { + fontSize: '1rem', + }, + }} + /> + ) + }, + } + } + const selectedManagedUser = users.find( (user: UserResponse) => user._id === selectedUserId, ) + const selectedManagedGroup = groups.find( + (group: GroupResponse) => group._id === selectedGroupId, + ) + + const filteredGroups = useMemo(() => { + const query = groupFilterText.trim().toLowerCase() + if (!query) { + return groups + } + + return groups.filter((group: GroupResponse) => { + const haystack = `${group.name} ${group.description ?? ''}`.toLowerCase() + return haystack.includes(query) + }) + }, [groupFilterText, groups]) + + const selectableGroups = useMemo(() => { + if (!selectedManagedGroup) { + return filteredGroups + } + if ( + filteredGroups.some( + (group: GroupResponse) => group._id === selectedManagedGroup._id, + ) + ) { + return filteredGroups + } + return [selectedManagedGroup, ...filteredGroups] + }, [filteredGroups, selectedManagedGroup]) const assemblyPermissionRows: AssemblyPermissionRow[] = buildAssemblyPermissionRows(assemblies, assemblyPermissionsByAssemblyId) + const effectiveAssemblyPermissionRows: EffectiveAssemblyPermissionRow[] = + assemblies + .map((assembly: AssemblyResponse) => { + const permission = + effectiveAssemblyPermissionsByAssemblyId[assembly._id] + return { + id: assembly._id, + assemblyId: assembly._id, + assemblyName: assembly.displayName ?? assembly.name, + canViewAnnotations: permission?.canViewAnnotations ?? false, + canEditAnnotations: permission?.canEditAnnotations ?? false, + source: permission?.source ?? 'none', + } + }) + .sort( + ( + a: EffectiveAssemblyPermissionRow, + b: EffectiveAssemblyPermissionRow, + ) => a.assemblyName.localeCompare(b.assemblyName), + ) + + const groupMembershipRows: GroupMembershipRow[] = users + .map((user: UserResponse) => ({ + id: user._id, + userId: user._id, + username: user.username, + email: user.email, + isMember: Boolean(groupMembershipByUserId[user._id]), + })) + .sort((a: GroupMembershipRow, b: GroupMembershipRow) => + a.username.localeCompare(b.username), + ) + + const groupAssemblyPermissionRows: AssemblyPermissionRow[] = + buildAssemblyPermissionRows(assemblies, groupPermissionsByAssemblyId).sort( + (a: AssemblyPermissionRow, b: AssemblyPermissionRow) => + a.assemblyName.localeCompare(b.assemblyName), + ) + + const enabledGroupMembershipRows = groupMembershipRows.filter( + (row: GroupMembershipRow) => row.isMember, + ) + + const enabledGroupAssemblyPermissionRows = groupAssemblyPermissionRows.filter( + (row: AssemblyPermissionRow) => + row.canViewAnnotations || row.canEditAnnotations, + ) + + const groupSummary = selectedManagedGroup + ? `${enabledGroupMembershipRows.length} members, ${enabledGroupAssemblyPermissionRows.length} assemblies with access` + : 'Create or select a group to manage inherited access.' + const assemblyPermissionColumns: GridColDef[] = [ { field: 'assemblyName', @@ -336,20 +957,226 @@ export function ManageUsers({ width: 280, editable: false, }, + buildTogglePermissionColumn( + 'canViewAnnotations', + 'Can view annotations', + async (row, checked) => { + await toggleAssemblyPermission(row, 'canViewAnnotations', checked) + }, + () => !selectedUserId, + ), + buildTogglePermissionColumn( + 'canEditAnnotations', + 'Can edit annotations', + async (row, checked) => { + await toggleAssemblyPermission(row, 'canEditAnnotations', checked) + }, + () => !selectedUserId, + ), + ] + + const effectiveAssemblyPermissionColumns: GridColDef[] = [ { - field: 'canViewAnnotations', - headerName: 'Can view annotations', - width: 210, - type: 'boolean', - editable: true, + field: 'assemblyName', + headerName: 'Assembly', + width: 280, + editable: false, }, + buildEffectivePermissionColumn('canViewAnnotations', 'Effective view'), + buildEffectivePermissionColumn('canEditAnnotations', 'Effective edit'), { - field: 'canEditAnnotations', - headerName: 'Can edit annotations', - width: 210, - type: 'boolean', - editable: true, + field: 'source', + headerName: 'Source', + width: 160, + editable: false, + type: 'singleSelect', + valueOptions: ['none', 'direct', 'group', 'mixed'], + }, + ] + + const groupMembershipColumns: GridColDef[] = [ + { + field: 'username', + headerName: 'User', + width: 180, + editable: false, + }, + { + field: 'email', + headerName: 'Email', + width: 240, + editable: false, + }, + { + field: 'isMember', + headerName: 'Member', + width: 140, + sortable: false, + editable: false, + renderCell: (params: GridRenderCellParams) => ( + ) => { + event.stopPropagation() + }} + onChange={( + _event: React.ChangeEvent, + checked: boolean, + ) => { + toggleGroupMembership(params.row, checked).catch((error) => { + setErrorMessage(String(error)) + }) + }} + /> + ), + }, + ] + + const groupMembershipStateColumns: GridColDef[] = [ + { + field: 'username', + headerName: 'User', + width: 180, + editable: false, + }, + { + field: 'email', + headerName: 'Email', + width: 240, + editable: false, + }, + ] + + const groupManagementControls = ( + <> + + ) => { + setNewGroupName(event.target.value) + }} + /> + ) => { + setNewGroupDescription(event.target.value) + }} + /> + + + + + ) => { + setGroupFilterText(event.target.value) + }} + sx={{ minWidth: 320, marginBottom: 1 }} + /> + + + Managed group + + + {selectedManagedGroup ? ( + + {`Managing ${selectedManagedGroup.name}: ${groupSummary}`} + + ) : ( + + {groupSummary} + + )} + {groupFilterText ? ( + + {`${filteredGroups.length} groups match "${groupFilterText.trim()}".`} + + ) : null} + + ) + + const groupAssemblyPermissionColumns: GridColDef[] = [ + { + field: 'assemblyName', + headerName: 'Assembly', + width: 280, + editable: false, + }, + buildTogglePermissionColumn( + 'canViewAnnotations', + 'Can view annotations', + async (row, checked) => { + await toggleGroupAssemblyPermission(row, 'canViewAnnotations', checked) + }, + () => !selectedGroupId, + ), + buildTogglePermissionColumn( + 'canEditAnnotations', + 'Can edit annotations', + async (row, checked) => { + await toggleGroupAssemblyPermission(row, 'canEditAnnotations', checked) + }, + () => !selectedGroupId, + ), + ] + + const groupAssemblyPermissionStateColumns: GridColDef[] = [ + { + field: 'assemblyName', + headerName: 'Assembly', + width: 280, + editable: false, }, + buildEffectivePermissionColumn('canViewAnnotations', 'View enabled'), + buildEffectivePermissionColumn('canEditAnnotations', 'Edit enabled'), ] return ( @@ -369,16 +1196,25 @@ export function ManageUsers({ onChange={handleChangeInternetAccount} disabled={!errorMessage} > - {internetAccounts.map( - ( - option: ApolloInternetAccountModel & { - id: string - internetAccountId: string - name: string - }, - ) => ( - - {option.name} + {apolloInternetAccounts.map( + (option: ApolloInternetAccountModel) => ( + + { + (option as ApolloInternetAccountModel & { name: string }) + .name + } ), )} @@ -386,15 +1222,31 @@ export function ManageUsers({ ) : null}
- - User roles - + + User roles + + row._id} slots={{ toolbar: GridToolbar }} + sx={apolloDataGridSx} getRowHeight={() => 'auto'} isCellEditable={(params: GridCellParams) => !isCurrentUser(params.id) @@ -408,40 +1260,248 @@ export function ManageUsers({ - Assembly permissions + Permission management - - Managed user - - - {selectedManagedUser ? ( - - {`Managing assembly access for ${selectedManagedUser.username}`} - + { + setPermissionView(value) + }} + sx={{ marginBottom: 2 }} + > + + + + + + + {permissionView === 'effective' || permissionView === 'assembly' ? ( + + + Managed user + + + + ) : null} + + {permissionView === 'effective' ? ( + <> + {selectedManagedUser ? ( + + {`Effective access for ${selectedManagedUser.username}`} + + ) : null} +
+ +
+ + ) : null} + + {permissionView === 'assembly' ? ( + <> + {selectedManagedUser ? ( + + {`Editing direct user access for ${selectedManagedUser.username}`} + + ) : null} +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + /> +
+ + ) : null} + + {permissionView === 'groupMemberships' ? ( + <> + + Group memberships + + {groupManagementControls} + + { + setGroupMembershipView(value) + }} + sx={{ marginBottom: 2 }} + > + + + + + {groupMembershipView === 'current' ? ( + <> + + {selectedManagedGroup + ? `${enabledGroupMembershipRows.length} users are currently members of this group.` + : 'Select a group to review its current memberships.'} + + + + Active group memberships + + {enabledGroupMembershipRows.length ? ( +
+ +
+ ) : ( + + No users are currently members of this group. + + )} + + ) : null} + + {groupMembershipView === 'edit' ? ( + <> + + Toggle group membership here. Changes are saved row by row. + + + + Group memberships + +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + isCellEditable={(params: GridCellParams) => + Boolean(selectedGroupId) && params.field === 'isMember' + } + /> +
+ + ) : null} + + ) : null} + + {permissionView === 'groupPermissions' ? ( + <> + + Group permissions + + {groupManagementControls} + + { + setGroupPermissionView(value) + }} + sx={{ marginBottom: 2 }} + > + + + + + {groupPermissionView === 'current' ? ( + <> + + {selectedManagedGroup + ? `${enabledGroupAssemblyPermissionRows.length} assemblies currently have inherited access in this group.` + : 'Select a group to review its current assembly permissions.'} + + + + Enabled assembly permissions + + {enabledGroupAssemblyPermissionRows.length ? ( +
+ +
+ ) : ( + + No assembly permissions are currently enabled for this + group. + + )} + + ) : null} + + {groupPermissionView === 'edit' ? ( + <> + + Toggle group assembly access here. Changes are saved row by + row. + + + + Group assembly permissions + +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + isCellEditable={(params: GridCellParams) => + Boolean(selectedGroupId) && + (params.field === 'canViewAnnotations' || + params.field === 'canEditAnnotations') + } + /> +
+ + ) : null} + ) : null} -
- { - setErrorMessage(String(error)) - }} - disableRowSelectionOnClick - /> -
@@ -454,6 +1514,79 @@ export function ManageUsers({ {errorMessage} ) : null} + {localUserDialogOpen ? ( + { + setLocalUserDialogOpen(false) + }} + maxWidth="sm" + > + + ) => { + setNewLocalUsername(event.target.value) + }} + /> + ) => { + setNewLocalEmail(event.target.value) + }} + /> + ) => { + setNewLocalPassword(event.target.value) + }} + /> + + Role + + + + + + + + + ) : null}
) } diff --git a/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx new file mode 100644 index 000000000..1c2a1bf9b --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx @@ -0,0 +1,323 @@ +/* eslint-disable @typescript-eslint/no-misused-promises */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { getRoot } from '@jbrowse/mobx-state-tree' +import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view' +import type { AbstractSessionModel } from '@jbrowse/core/util' +import FilterListIcon from '@mui/icons-material/FilterList' +import { + Box, + Button, + Chip, + DialogActions, + DialogContent, + DialogContentText, + FormControlLabel, + Switch, + Typography, +} from '@mui/material' +import { DataGrid, type GridColDef, GridToolbar } from '@mui/x-data-grid' +import React, { useEffect, useMemo, useState } from 'react' + +import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' +import type { ApolloSessionModel } from '../session' +import { type ApolloRootModel, isApolloInternetAccount } from '../types' +import { createFetchErrorMessage } from '../util' + +import { Dialog } from './Dialog' +import { apolloDataGridSx } from './dataGridStyles' + +interface AssemblyResponse { + _id: string + name: string + displayName?: string +} + +interface AssemblyPermissionResponse { + _id?: string + userId: string + assemblyId: string + canViewAnnotations: boolean + canEditAnnotations: boolean +} + +interface PermissionRow { + id: string + assemblyId: string + assemblyName: string + access: 'Edit' | 'View' +} + +interface MyAssemblyPermissionsProps { + session: ApolloSessionModel + handleClose(): void +} + +interface SessionWithLinearGenomeView extends AbstractSessionModel { + views: unknown[] + addView?: (viewType: string, ...args: unknown[]) => unknown +} + +function wait(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) +} + +export function MyAssemblyPermissions({ + handleClose, + session, +}: MyAssemblyPermissionsProps) { + const { internetAccounts } = getRoot(session) + const apolloInternetAccounts: ApolloInternetAccountModel[] = internetAccounts + .filter(isApolloInternetAccount) + .filter((ia) => Boolean(ia.retrieveToken())) + + if (apolloInternetAccounts.length === 0) { + throw new Error('No authenticated Apollo internet account found') + } + + const [selectedInternetAccount] = useState(apolloInternetAccounts[0]) + const [rows, setRows] = useState([]) + const [errorMessage, setErrorMessage] = useState('') + const [loading, setLoading] = useState(true) + const [editOnly, setEditOnly] = useState(false) + + useEffect(() => { + async function loadPermissions() { + setLoading(true) + setErrorMessage('') + const { baseURL } = selectedInternetAccount + const assembliesUri = new URL('assemblies', baseURL).href + const permissionsUri = new URL('assemblyPermissions/mine', baseURL).href + const apolloFetchAssemblies = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: assembliesUri, + }) + const apolloFetchPermissions = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri: permissionsUri, + }) + const [assembliesResponse, permissionsResponse] = await Promise.all([ + apolloFetchAssemblies(assembliesUri, { method: 'GET' }), + apolloFetchPermissions(permissionsUri, { method: 'GET' }), + ]) + + if (!assembliesResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + assembliesResponse, + 'Error when loading assemblies', + ) + throw new Error(newErrorMessage) + } + if (!permissionsResponse.ok) { + const newErrorMessage = await createFetchErrorMessage( + permissionsResponse, + 'Error when loading your annotation permissions', + ) + throw new Error(newErrorMessage) + } + + const assemblies = (await assembliesResponse.json()) as AssemblyResponse[] + const permissions = + (await permissionsResponse.json()) as AssemblyPermissionResponse[] + const assemblyById = new Map( + assemblies.map((assembly) => [assembly._id, assembly]), + ) + + const nextRows: PermissionRow[] = permissions + .filter((permission) => permission.canViewAnnotations) + .map((permission) => { + const assembly = assemblyById.get(permission.assemblyId) + const assemblyName = + assembly?.displayName ?? assembly?.name ?? permission.assemblyId + return { + id: + permission._id ?? `${permission.userId}-${permission.assemblyId}`, + assemblyId: permission.assemblyId, + assemblyName, + access: permission.canEditAnnotations + ? ('Edit' as const) + : ('View' as const), + } + }) + .sort((a, b) => a.assemblyName.localeCompare(b.assemblyName)) + + setRows(nextRows) + setLoading(false) + } + + loadPermissions().catch((error) => { + setErrorMessage(String(error)) + setRows([]) + setLoading(false) + }) + }, [selectedInternetAccount]) + + const filteredRows = useMemo( + () => rows.filter((row) => !editOnly || row.access === 'Edit'), + [editOnly, rows], + ) + + async function loadAssembly(assemblyId: string) { + setErrorMessage('') + const sessionModel = session as unknown as SessionWithLinearGenomeView + let linearGenomeView = sessionModel.views.find( + (view) => + (view as { type?: string } | undefined)?.type === 'LinearGenomeView', + ) as unknown as LinearGenomeViewModel | undefined + + if (!linearGenomeView && sessionModel.addView) { + // Create a linear genome view on-demand so Load works from the start screen. + const createdView = sessionModel.addView('LinearGenomeView') + linearGenomeView = + (createdView as LinearGenomeViewModel | undefined) ?? + (sessionModel.views.find( + (view) => + (view as { type?: string } | undefined)?.type === + 'LinearGenomeView', + ) as unknown as LinearGenomeViewModel | undefined) + } + + if (!linearGenomeView) { + setErrorMessage('Could not open a Linear Genome View for this session.') + return + } + + let lastError: unknown + for (let attempt = 0; attempt < 8; attempt++) { + try { + linearGenomeView.showAllRegionsInAssembly(assemblyId) + handleClose() + return + } catch (error) { + lastError = error + await wait(150) + } + } + + setErrorMessage(`Could not load assembly: ${String(lastError)}`) + } + + const gridColumns: GridColDef[] = [ + { + field: 'loadAssembly', + headerName: 'Load', + sortable: false, + filterable: false, + minWidth: 110, + maxWidth: 120, + renderCell: ({ row }) => ( + + ), + }, + { + field: 'assemblyName', + headerName: 'Assembly', + flex: 1, + minWidth: 220, + }, + { + field: 'access', + headerName: 'Annotation access', + minWidth: 180, + renderCell: ({ row }) => ( + : undefined} + /> + ), + }, + ] + + const editCount = rows.filter((row) => row.access === 'Edit').length + + return ( + + + + + Assemblies in your Apollo workspace where you can view or edit + annotations. + + { + setEditOnly(event.target.checked) + }} + /> + } + label="Edit-only" + /> + + + {filteredRows.length} shown ({editCount} with Edit access) + +
+ +
+ {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ + + + +
+ ) +} diff --git a/packages/jbrowse-plugin-apollo/src/components/ViewChangeLog.tsx b/packages/jbrowse-plugin-apollo/src/components/ViewChangeLog.tsx index 1867ebf6a..6d047a595 100644 --- a/packages/jbrowse-plugin-apollo/src/components/ViewChangeLog.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/ViewChangeLog.tsx @@ -26,6 +26,7 @@ import type { GetChangesOpts } from '../BackendDrivers/BackendDriver' import type { ApolloSessionModel } from '../session' import { Dialog } from './Dialog' +import { apolloDataGridSx } from './dataGridStyles' interface ViewChangeLogProps { session: ApolloSessionModel @@ -216,6 +217,7 @@ export function ViewChangeLog({ columns={gridColumns} getRowId={(row) => row.sequence} showToolbar + sx={apolloDataGridSx} pageSizeOptions={[5, 15, 25, 50, 100]} initialState={{ columns: { columnVisibilityModel: { sequence: false } }, diff --git a/packages/jbrowse-plugin-apollo/src/components/ViewCheckResults.tsx b/packages/jbrowse-plugin-apollo/src/components/ViewCheckResults.tsx index 21d3124b7..0eda7d237 100644 --- a/packages/jbrowse-plugin-apollo/src/components/ViewCheckResults.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/ViewCheckResults.tsx @@ -14,6 +14,7 @@ import React, { useEffect, useState } from 'react' import type { ApolloSessionModel } from '../session' import { Dialog } from './Dialog' +import { apolloDataGridSx } from './dataGridStyles' interface ViewCheckResultsProps { session: ApolloSessionModel @@ -75,6 +76,7 @@ export function ViewCheckResults({ columns={gridColumns} getRowId={(row) => row._id} showToolbar + sx={apolloDataGridSx} initialState={{ sorting: { sortModel: [{ field: 'name', sort: 'asc' }] }, columns: { columnVisibilityModel: { name: true } }, diff --git a/packages/jbrowse-plugin-apollo/src/components/dataGridStyles.ts b/packages/jbrowse-plugin-apollo/src/components/dataGridStyles.ts new file mode 100644 index 000000000..8c13621a5 --- /dev/null +++ b/packages/jbrowse-plugin-apollo/src/components/dataGridStyles.ts @@ -0,0 +1,42 @@ +export const apolloDataGridSx = { + border: '1px solid', + borderColor: 'divider', + borderRadius: 3, + backgroundColor: 'background.paper', + overflow: 'hidden', + '& .MuiDataGrid-toolbarContainer': { + padding: '10px 12px', + borderBottom: '1px solid', + borderColor: 'divider', + backgroundColor: 'grey.50', + }, + '& .MuiDataGrid-columnHeaders': { + backgroundColor: 'grey.100', + borderBottom: '1px solid', + borderColor: 'divider', + }, + '& .MuiDataGrid-columnHeaderTitle': { + fontWeight: 700, + color: 'text.primary', + letterSpacing: '0.01em', + }, + '& .MuiDataGrid-columnSeparator': { + color: 'divider', + }, + '& .MuiDataGrid-cell': { + alignItems: 'center', + borderColor: 'divider', + borderBottomColor: 'divider', + }, + '& .MuiDataGrid-row:nth-of-type(even)': { + backgroundColor: 'grey.50', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'action.hover', + }, + '& .MuiDataGrid-footerContainer': { + borderTop: '1px solid', + borderColor: 'divider', + backgroundColor: 'grey.50', + }, +} as const diff --git a/packages/jbrowse-plugin-apollo/src/components/index.ts b/packages/jbrowse-plugin-apollo/src/components/index.ts index bc7c05000..6a0f53997 100644 --- a/packages/jbrowse-plugin-apollo/src/components/index.ts +++ b/packages/jbrowse-plugin-apollo/src/components/index.ts @@ -10,6 +10,7 @@ export * from './DownloadGFF3' export * from './ImportFeatures' export * from './LogOut' export * from './ManageChecks' +export * from './MyAssemblyPermissions' export * from './ManageUsers' export * from './MergeExons' export * from './MergeTranscripts' diff --git a/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts b/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts index cf73cd3c4..bc2a2170d 100644 --- a/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts +++ b/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts @@ -12,6 +12,12 @@ export interface AssemblyPermissionResponse { canEditAnnotations: boolean } +export interface AssemblyPermissionLike { + assemblyId: string + canViewAnnotations: boolean + canEditAnnotations: boolean +} + export interface AssemblyPermissionRow { id: string assemblyId: string @@ -47,7 +53,7 @@ export function normalizeAssemblyPermissionUpdate( export function buildAssemblyPermissionRows( assemblies: AssemblyResponse[], - permissionsByAssemblyId: Partial>, + permissionsByAssemblyId: Partial>, ): AssemblyPermissionRow[] { return assemblies.map((assembly) => { const permission = permissionsByAssemblyId[assembly._id] diff --git a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts index 9f31d7f02..51f8f5627 100644 --- a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts +++ b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts @@ -2,11 +2,12 @@ import type { AbstractMenuManager, AbstractSessionModel, } from '@jbrowse/core/util' +import FactCheckIcon from '@mui/icons-material/FactCheck' import LogoutIcon from '@mui/icons-material/Logout' import RedoIcon from '@mui/icons-material/Redo' import UndoIcon from '@mui/icons-material/Undo' -import { LogOut } from '../components' +import { LogOut, MyAssemblyPermissions } from '../components' import type { ApolloSessionModel } from '../session' import { type ApolloRootModel, isApolloInternetAccount } from '../types' @@ -47,6 +48,24 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { isApolloInternetAccount(ia), ) if (hasApolloInternetAccount) { + rootModel.appendToMenu('Apollo', { + label: 'My workspace', + icon: FactCheckIcon, + onClick: (session: ApolloSessionModel) => { + ;(session as unknown as AbstractSessionModel).queueDialog( + (doneCallback) => [ + MyAssemblyPermissions, + { + session, + handleClose: () => { + doneCallback() + }, + }, + ], + ) + }, + }) + rootModel.appendToMenu('Apollo', { label: 'Log out', icon: LogoutIcon, diff --git a/packages/jbrowse-plugin-apollo/src/session/session.ts b/packages/jbrowse-plugin-apollo/src/session/session.ts index 2e4794003..dcee60413 100644 --- a/packages/jbrowse-plugin-apollo/src/session/session.ts +++ b/packages/jbrowse-plugin-apollo/src/session/session.ts @@ -69,6 +69,41 @@ export function extendSession( pluginManager: PluginManager, sessionModel: ReturnType, ) { + const normalizeConfigAssemblyNames = (config: JBrowseConfig) => { + type ConfigTrack = NonNullable[number] + + const assemblies = config.assemblies ?? [] + const displayNameToAssemblyName = new Map() + for (const assembly of assemblies) { + if (assembly.displayName && assembly.displayName !== assembly.name) { + displayNameToAssemblyName.set(assembly.displayName, assembly.name) + } + } + + if (displayNameToAssemblyName.size === 0 || !config.tracks?.length) { + return config + } + + const normalizedTracks = config.tracks.map((track: ConfigTrack) => { + if (!track.assemblyNames?.length) { + return track + } + const normalizedAssemblyNames = track.assemblyNames.map( + (assemblyName: string) => + displayNameToAssemblyName.get(assemblyName) ?? assemblyName, + ) + return { + ...track, + assemblyNames: normalizedAssemblyNames, + } + }) + + return { + ...config, + tracks: normalizedTracks, + } + } + const AnnotationFeatureExtended = pluginManager.evaluateExtensionPoint( 'Apollo-extendAnnotationFeature', AnnotationFeatureModel, @@ -306,6 +341,17 @@ export function extendSession( const hasApolloInternetAccount = internetAccounts.some((ia) => isApolloInternetAccount(ia), ) + // Re-run this autorun after login/logout so role-gated config can be reloaded. + const apolloAuthSignature = internetAccounts + .filter(isApolloInternetAccount) + .map( + (ia) => + `${ia.internetAccountId}:${ia.role ?? ''}:${Boolean( + ia.retrieveToken(), + )}`, + ) + .join('|') + void apolloAuthSignature const nonApolloAssemblies = ( self as unknown as AbstractSessionModel ).assemblyManager.assemblies.filter( @@ -391,9 +437,12 @@ export function extendSession( if (!jbrowseConfig.configuration.ApolloPlugin.hasRole) { continue } + const normalizedJBrowseConfig = normalizeConfigAssemblyNames( + jbrowseConfig as JBrowseConfig, + ) // eslint-disable-next-line @typescript-eslint/no-unsafe-call reloadPluginManagerCallback( - jbrowseConfig, + normalizedJBrowseConfig, self.previousSnapshot, ) reaction.dispose() From 681778bc4a2b7840cfdc995b73ca2bd9d7e21b0c Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:01:36 -0400 Subject: [PATCH 08/31] Improve Apollo login UX and document local workflow - Keep local login dialog open on invalid credentials with explicit error - Add signed-in identity indicator updates in plugin menu/session flows - Refresh root/plugin/server README docs with current integration status Assisted-by: GitHub Copilot (GPT-5.3-Codex) --- README.md | 26 ++ .../apollo-collaboration-server/README.md | 30 +++ packages/jbrowse-plugin-apollo/README.md | 17 ++ .../components/AuthTypeSelector.tsx | 51 +++- .../src/ApolloInternetAccount/model.ts | 233 ++++++++++++------ .../src/components/LogOut.tsx | 9 +- .../src/components/ManageUsers.tsx | 104 +++++--- .../src/components/MyAssemblyPermissions.tsx | 65 ++++- .../src/menus/topLevelMenu.ts | 77 +++++- 9 files changed, 474 insertions(+), 138 deletions(-) diff --git a/README.md b/README.md index 5a4b3294b..549efc9ba 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,32 @@ Monorepo for Apollo3 development +## Current integration status (2026-06-09) + +- Plugin authentication UX now surfaces failed local login attempts clearly + instead of appearing to pass through. +- Apollo plugin menu now includes a visible signed-in indicator + (`Signed in as: ` when available). +- Local USDA integration stack currently expects the plugin UMD bundle from + `packages/jbrowse-plugin-apollo/dist/jbrowse-plugin-apollo.umd.development.js`. + +## Local integration workflow + +For the NAL local stack (served through `jbrowse-apollo-infra`): + +1. Build shared package if needed: + - `yarn --cwd packages/apollo-shared build` +2. Build plugin bundle: + - `yarn --cwd packages/jbrowse-plugin-apollo build` +3. Start/restart infra stack from `../jbrowse-apollo-infra`: + - `./scripts/dev/start_local_stack.sh` + +## Where to look next + +- Infrastructure status and run commands: `../jbrowse-apollo-infra/README.md` +- Active handoff notes and open risks: + `../jbrowse-apollo-infra/HANDOFF_APOLLO3_JBROWSE2.md` + | Package | Description | | ---------------------------------------------------------------------- | ---------------------------------------------------------- | | [apollo-collaboration-server](./packages/apollo-collaboration-server/) | Server-side code | diff --git a/packages/apollo-collaboration-server/README.md b/packages/apollo-collaboration-server/README.md index ca2522734..a52b0e1cc 100644 --- a/packages/apollo-collaboration-server/README.md +++ b/packages/apollo-collaboration-server/README.md @@ -1 +1,31 @@ # apollo-collaboration-server + +NestJS-based Apollo3 backend API used by the JBrowse Apollo plugin. + +## Local development + +From the Apollo3 repo root: + +```bash +yarn --cwd packages/apollo-shared build +yarn --cwd packages/apollo-collaboration-server start +``` + +Default local API port is `3999`. + +## Auth endpoints used by plugin + +- `GET /auth/types` +- `POST /auth/local` +- `POST /auth/guest` + +## Health checks + +- `GET /health` + +## Notes + +- Source of truth for stack orchestration and reverse proxy behavior is in + `../jbrowse-apollo-infra`. +- For integrated local bring-up, prefer infra scripts over ad-hoc startup: + `../jbrowse-apollo-infra/scripts/dev/start_local_stack.sh`. diff --git a/packages/jbrowse-plugin-apollo/README.md b/packages/jbrowse-plugin-apollo/README.md index 474c9b56f..82e44cc23 100644 --- a/packages/jbrowse-plugin-apollo/README.md +++ b/packages/jbrowse-plugin-apollo/README.md @@ -1,5 +1,22 @@ # jbrowse-plugin-apollo +## Current auth UX behavior (2026-06-09) + +- Local login now validates credentials before closing the dialog. +- Invalid credentials show an in-dialog error + (`Invalid username/email or password`). +- While local auth is in flight, the local sign-in button is disabled and shows + `Signing in...`. +- Top-level Apollo menu includes a signed-in indicator when a token is present. + +## Quick local auth verification + +1. Open JBrowse and select Apollo login. +2. Attempt local login with an incorrect password. +3. Confirm the dialog remains open and shows an explicit error. +4. Retry with valid credentials. +5. Confirm login succeeds and menu label updates to signed-in identity. + ## Testing with cypress These notes setup cypress and run tests. These notes are likely to change. diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx index 899656d71..56ccedff3 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx @@ -51,6 +51,7 @@ export const AuthTypeSelector = ({ const [loginTypes, setLoginTypes] = useState([]) const [identifier, setIdentifier] = useState('') const [password, setPassword] = useState('') + const [isSubmittingLocal, setIsSubmittingLocal] = useState(false) useEffect(() => { const controller = new AbortController() const { signal } = controller @@ -97,6 +98,45 @@ export const AuthTypeSelector = ({ } } + async function submitLocalLogin() { + if (!identifier || !password) { + setErrorMessage('Local login requires username/email and password') + return + } + + setIsSubmittingLocal(true) + setErrorMessage('') + try { + const uri = new URL('auth/local', baseURL).href + const response = await fetch(uri, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ identifier, password }), + }) + + if (!response.ok) { + if (response.status === 401) { + setErrorMessage('Invalid username/email or password') + return + } + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when logging in locally', + ) + setErrorMessage(newErrorMessage) + return + } + + handleClose({ type: 'local', identifier, password }) + } catch (error) { + setErrorMessage(String(error)) + } finally { + setIsSubmittingLocal(false) + } + } + const allowGoogle = loginTypes.includes('google') const allowMicrosoft = loginTypes.includes('microsoft') const allowLoginGov = loginTypes.includes('logingov') @@ -167,17 +207,12 @@ export const AuthTypeSelector = ({ /> diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts index 0804fcd65..03d9806d4 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts @@ -103,7 +103,8 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { const fetcher = superGetFetcher(loc) return async (input: RequestInfo, init?: RequestInit) => { const response = await fetcher(input, init) - if (response.status === 403) { + // Only invalidate local token on explicit auth failure. + if (response.status === 401) { self.removeToken() self.setRole() return fetcher(input, init) @@ -238,7 +239,21 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { const { baseURL } = self const authType = await new Promise( (resolve: (authType: AuthSelection) => void, reject) => { - const { session } = getRoot(self) + if (!isAlive(self)) { + reject(new Error('Apollo session is no longer available')) + return + } + let session: ApolloRootModel['session'] | undefined + try { + session = getRoot(self).session + } catch { + reject(new Error('No active session available for login dialog')) + return + } + if (!session || !isAlive(session)) { + reject(new Error('No active session available for login dialog')) + return + } const { baseURL, name } = self ;(session as unknown as AbstractSessionModel).queueDialog( (doneCallback: () => void) => [ @@ -354,7 +369,18 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { }, ), getMissingChanges: flow(function* getMissingChanges() { - const { session } = getRoot(self) + if (!isAlive(self)) { + return + } + let session: ApolloRootModel['session'] | undefined + try { + session = getRoot(self).session + } catch { + return + } + if (!session || !isAlive(session)) { + return + } const { changeManager } = session.apolloDataStore if (!self.lastChangeSequenceNumber) { throw new Error( @@ -404,68 +430,100 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { const { origin, pathname: path } = new URL('socket.io/', self.baseURL) return { socket: io(origin, { path }) } }) - .actions((self) => ({ - addSocketListeners() { - const { session } = getRoot(self) - const { notify } = session as unknown as AbstractSessionModel - const token = self.retrieveToken() - if (!token) { - throw new Error('No Token found') + .actions((self) => { + function getLiveSession() { + if (!isAlive(self)) { + return undefined } - const user = getDecodedToken(token) - const localSessionId = makeUserSessionId(user) - const { socket } = self - const { addCheckResult, changeManager, deleteCheckResult } = - session.apolloDataStore - socket.on('connect', () => { - void self.getMissingChanges() - }) - socket.on('connect_error', (error) => { - console.error(error) - notify('Could not connect to the Apollo server.', 'error') - }) - socket.on('COMMON', (message: ChangeMessage | CheckResultUpdate) => { - if ('checkResult' in message) { - if (message.deleted) { - deleteCheckResult(message.checkResult._id.toString()) - } else { - addCheckResult(message.checkResult) - } - return - } - // Save server last change sequence into session storage - sessionStorage.setItem( - 'LastChangeSequence', - String(message.changeSequence), - ) - if (message.userSessionId === localSessionId) { - return // we did this change, no need to apply it again + try { + const { session } = getRoot(self) + return session && isAlive(session) ? session : undefined + } catch { + return undefined + } + } + + return { + addSocketListeners() { + const token = self.retrieveToken() + if (!token) { + throw new Error('No Token found') } - const change = Change.fromJSON(message.changeInfo) - void changeManager.submit(change, { submitToBackend: false }) - }) - socket.on('USER_LOCATION', (message: UserLocationMessage) => { - const { channel, locations, userName, userSessionId } = message - if (channel === 'USER_LOCATION' && userSessionId !== localSessionId) { - const collaborator: Collaborator = { - name: userName, - id: userSessionId, - locations, + const user = getDecodedToken(token) + const localSessionId = makeUserSessionId(user) + const { socket } = self + socket.on('connect', () => { + void self.getMissingChanges() + }) + socket.on('connect_error', (error) => { + console.error(error) + ;( + getLiveSession() as unknown as AbstractSessionModel | undefined + )?.notify('Could not connect to the Apollo server.', 'error') + }) + socket.on('COMMON', (message: ChangeMessage | CheckResultUpdate) => { + const session = getLiveSession() + if (!session) { + return } - session.addOrUpdateCollaborator(collaborator) - } - }) - socket.on( - 'REQUEST_INFORMATION', - (message: RequestUserInformationMessage) => { - const { channel, userSessionId } = message - if (channel === 'REQUEST_INFORMATION' && userSessionId !== token) { - session.broadcastLocations() + const { addCheckResult, changeManager, deleteCheckResult } = + session.apolloDataStore + if ('checkResult' in message) { + if (message.deleted) { + deleteCheckResult(message.checkResult._id.toString()) + } else { + addCheckResult(message.checkResult) + } + return } - }, - ) - }, - })) + // Save server last change sequence into session storage + sessionStorage.setItem( + 'LastChangeSequence', + String(message.changeSequence), + ) + if (message.userSessionId === localSessionId) { + return // we did this change, no need to apply it again + } + const change = Change.fromJSON(message.changeInfo) + void changeManager.submit(change, { submitToBackend: false }) + }) + socket.on('USER_LOCATION', (message: UserLocationMessage) => { + const session = getLiveSession() + if (!session) { + return + } + const { channel, locations, userName, userSessionId } = message + if ( + channel === 'USER_LOCATION' && + userSessionId !== localSessionId + ) { + const collaborator: Collaborator = { + name: userName, + id: userSessionId, + locations, + } + session.addOrUpdateCollaborator(collaborator) + } + }) + socket.on( + 'REQUEST_INFORMATION', + (message: RequestUserInformationMessage) => { + const session = getLiveSession() + if (!session) { + return + } + const { channel, userSessionId } = message + if ( + channel === 'REQUEST_INFORMATION' && + userSessionId !== token + ) { + session.broadcastLocations() + } + }, + ) + }, + } + }) .actions((self) => { async function postUserLocation(userLoc: UserLocation[]) { if (!isAlive(self) || self.role === 'none') { @@ -508,6 +566,18 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { }) .volatile(() => ({ roleNotificationSent: false })) .actions((self) => { + function getLiveSession() { + if (!isAlive(self)) { + return undefined + } + try { + const { session } = getRoot(self) + return session && isAlive(session) ? session : undefined + } catch { + return undefined + } + } + function beforeUnloadListener() { self.postUserLocation([]) } @@ -518,16 +588,19 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { } // fires when app transitions from prerender, user returns to the app / tab. if (document.visibilityState === 'visible') { - const { session } = getRoot(self) - session.broadcastLocations() + getLiveSession()?.broadcastLocations() } } return { initialize: flow(function* initialize(role: Role) { + if (!isAlive(self)) { + return + } if (role === 'none') { if (!self.roleNotificationSent) { - const { session } = getRoot(self) - ;(session as unknown as AbstractSessionModel).notify( + ;( + getLiveSession() as unknown as AbstractSessionModel | undefined + )?.notify( 'You have registered as an Apollo user but have not been given access. Ask your administrator to enable access for your account.', 'warning', ) @@ -536,7 +609,15 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { return } if (role === 'admin') { - const rootModel = getRoot(self) + if (!isAlive(self)) { + return + } + let rootModel: unknown + try { + rootModel = getRoot(self) + } catch { + return + } if (isAbstractMenuManager(rootModel)) { addTopLevelAdminMenus(rootModel) } @@ -552,10 +633,16 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { locationType: 'UriLocation', uri, }) - yield apolloFetch(uri, { - method: 'GET', - signal: self.controller.signal, - }) + try { + yield apolloFetch(uri, { + method: 'GET', + signal: self.controller.signal, + }) + } catch { + if (!self.controller.signal.aborted) { + console.error('Fetching user locations failed') + } + } window.addEventListener('beforeunload', beforeUnloadListener) document.addEventListener( 'visibilitychange', @@ -581,11 +668,7 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { if (inWebWorker) { return } - const { session } = getRoot(self) - // This can be undefined if there is no session loaded, e.g. on - // the start screen - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (!session) { + if (!isAlive(self)) { return } if (self.role) { diff --git a/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx b/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx index d5106997a..aa0ddef06 100644 --- a/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -import { getRoot } from '@jbrowse/mobx-state-tree' import { Button, DialogActions, @@ -12,18 +10,17 @@ import { import React, { useState } from 'react' import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' -import type { ApolloSessionModel } from '../session' import type { ApolloRootModel } from '../types' import { Dialog } from './Dialog' interface DeleteAssemblyProps { - session: ApolloSessionModel + rootModel: ApolloRootModel handleClose(): void } -export function LogOut({ handleClose, session }: DeleteAssemblyProps) { - const { internetAccounts } = getRoot(session) +export function LogOut({ handleClose, rootModel }: DeleteAssemblyProps) { + const { internetAccounts } = rootModel const [errorMessage, setErrorMessage] = useState('') const apolloInternetAccounts = internetAccounts.filter( (ia) => ia.type === 'ApolloInternetAccount', diff --git a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx index e2618e273..b03e282ef 100644 --- a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx @@ -176,6 +176,8 @@ export function ManageUsers({ const [newLocalEmail, setNewLocalEmail] = useState('') const [newLocalPassword, setNewLocalPassword] = useState('') const [newLocalRole, setNewLocalRole] = useState('user') + const [localUserErrorMessage, setLocalUserErrorMessage] = useState('') + const [isCreatingLocalUser, setIsCreatingLocalUser] = useState(false) useEffect(() => { async function getUsers() { @@ -407,41 +409,62 @@ export function ManageUsers({ } async function createLocalUser() { - const { baseURL } = selectedInternetAccount - const uri = new URL('users/local', baseURL).href - const apolloFetch = selectedInternetAccount.getFetcher({ - locationType: 'UriLocation', - uri, - }) - const response = await apolloFetch(uri, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - username: newLocalUsername, - email: newLocalEmail, - password: newLocalPassword, - role: newLocalRole === 'none' ? undefined : newLocalRole, - }), - }) + const username = newLocalUsername.trim() + const password = newLocalPassword.trim() + const email = newLocalEmail.trim() || username - if (!response.ok) { - const newErrorMessage = await createFetchErrorMessage( - response, - 'Error when creating local user', - ) - throw new Error(newErrorMessage) + if (!username || !password) { + setLocalUserErrorMessage('Username and password are required.') + return } - const createdUser = (await response.json()) as UserResponse - setUsers((prevUsers: UserResponse[]) => [...prevUsers, createdUser]) - setSelectedUserId(createdUser._id) - setLocalUserDialogOpen(false) - setNewLocalUsername('') - setNewLocalEmail('') - setNewLocalPassword('') - setNewLocalRole('user') + setIsCreatingLocalUser(true) + setLocalUserErrorMessage('') + + try { + const { baseURL } = selectedInternetAccount + const uri = new URL('users/local', baseURL).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + email, + password, + role: newLocalRole === 'none' ? undefined : newLocalRole, + }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when creating local user', + ) + throw new Error(newErrorMessage) + } + + const createdUser = (await response.json()) as UserResponse + setUsers((prevUsers: UserResponse[]) => [...prevUsers, createdUser]) + setSelectedUserId(createdUser._id) + setLocalUserDialogOpen(false) + setNewLocalUsername('') + setNewLocalEmail('') + setNewLocalPassword('') + setNewLocalRole('user') + setLocalUserErrorMessage('') + } catch (error) { + const message = String(error) + setLocalUserErrorMessage(message) + setErrorMessage(message) + } finally { + setIsCreatingLocalUser(false) + } } function isCurrentUser(id: GridRowId) { @@ -1234,6 +1257,7 @@ export function ManageUsers({ + {localUserErrorMessage ? ( + + + {localUserErrorMessage} + + + ) : null}
) : null} diff --git a/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx index 1c2a1bf9b..905d0c879 100644 --- a/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx @@ -1,8 +1,7 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { getRoot } from '@jbrowse/mobx-state-tree' +import { getRoot, isAlive } from '@jbrowse/mobx-state-tree' import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view' -import type { AbstractSessionModel } from '@jbrowse/core/util' import FilterListIcon from '@mui/icons-material/FilterList' import { Box, @@ -48,11 +47,11 @@ interface PermissionRow { } interface MyAssemblyPermissionsProps { - session: ApolloSessionModel + rootModel: ApolloRootModel handleClose(): void } -interface SessionWithLinearGenomeView extends AbstractSessionModel { +type SessionWithLinearGenomeView = { views: unknown[] addView?: (viewType: string, ...args: unknown[]) => unknown } @@ -65,17 +64,12 @@ function wait(ms: number) { export function MyAssemblyPermissions({ handleClose, - session, + rootModel, }: MyAssemblyPermissionsProps) { - const { internetAccounts } = getRoot(session) + const { internetAccounts } = rootModel const apolloInternetAccounts: ApolloInternetAccountModel[] = internetAccounts .filter(isApolloInternetAccount) .filter((ia) => Boolean(ia.retrieveToken())) - - if (apolloInternetAccounts.length === 0) { - throw new Error('No authenticated Apollo internet account found') - } - const [selectedInternetAccount] = useState(apolloInternetAccounts[0]) const [rows, setRows] = useState([]) const [errorMessage, setErrorMessage] = useState('') @@ -84,6 +78,12 @@ export function MyAssemblyPermissions({ useEffect(() => { async function loadPermissions() { + if (!selectedInternetAccount) { + setRows([]) + setLoading(false) + setErrorMessage('Sign in to Apollo, then reopen My workspace.') + return + } setLoading(true) setErrorMessage('') const { baseURL } = selectedInternetAccount @@ -160,7 +160,24 @@ export function MyAssemblyPermissions({ async function loadAssembly(assemblyId: string) { setErrorMessage('') - const sessionModel = session as unknown as SessionWithLinearGenomeView + if ( + !isAlive(rootModel) || + !rootModel.session || + !isAlive(rootModel.session) + ) { + setErrorMessage( + 'The current session is no longer available. Reopen My workspace.', + ) + return + } + const sessionModel = + rootModel.session as unknown as SessionWithLinearGenomeView + if (!sessionModel) { + setErrorMessage( + 'The current session is no longer available. Reopen My workspace.', + ) + return + } let linearGenomeView = sessionModel.views.find( (view) => (view as { type?: string } | undefined)?.type === 'LinearGenomeView', @@ -257,6 +274,30 @@ export function MyAssemblyPermissions({ const editCount = rows.filter((row) => row.access === 'Edit').length + if (!selectedInternetAccount) { + return ( + + + + No authenticated Apollo session was found. Sign in from the Apollo + menu and reopen My workspace. + + + + + + + ) + } + return ( Boolean(ia.retrieveToken())) as + | ApolloInternetAccountModel + | undefined +} + +function getCurrentApolloUserLabel(rootModel: ApolloRootModel) { + const signedInAccount = getSignedInApolloAccount(rootModel) + if (!signedInAccount) { + return 'Signed in as: not signed in' + } + const token = signedInAccount.retrieveToken() + if (!token) { + return 'Signed in as: not signed in' + } + try { + const { username, email } = getDecodedToken(token) + const identity = username || email || 'unknown user' + return `Signed in as: ${identity}` + } catch { + return 'Signed in as: token unreadable' + } +} + +function ApolloUserMenuLabel({ rootModel }: { rootModel: ApolloRootModel }) { + return getCurrentApolloUserLabel(rootModel) +} + +function notifyCurrentApolloUser( + rootModel: ApolloRootModel, + session: ApolloSessionModel, +) { + const signedInAccount = getSignedInApolloAccount(rootModel) + const sessionModel = session as unknown as AbstractSessionModel + if (!signedInAccount) { + sessionModel.notify('Apollo login status: not signed in', 'warning') + return + } + const token = signedInAccount.retrieveToken() + if (!token) { + sessionModel.notify('Apollo login status: not signed in', 'warning') + return + } + try { + const { username, email, role } = getDecodedToken(token) + const identity = username || email || 'unknown user' + const roleText = role ? ` (${role})` : '' + sessionModel.notify(`Apollo login status: ${identity}${roleText}`, 'info') + } catch { + sessionModel.notify('Apollo login status: token unreadable', 'warning') + } +} + export function addTopLevelMenus(rootModel: AbstractMenuManager) { rootModel.insertInMenu( 'Apollo', @@ -48,6 +108,19 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { isApolloInternetAccount(ia), ) if (hasApolloInternetAccount) { + rootModel.appendToMenu('Apollo', { + label: React.createElement(ApolloUserMenuLabel, { + rootModel: rootModel as unknown as ApolloRootModel, + }), + icon: AccountCircleIcon, + onClick: (session: ApolloSessionModel) => { + notifyCurrentApolloUser( + rootModel as unknown as ApolloRootModel, + session, + ) + }, + }) + rootModel.appendToMenu('Apollo', { label: 'My workspace', icon: FactCheckIcon, @@ -56,7 +129,7 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { (doneCallback) => [ MyAssemblyPermissions, { - session, + rootModel: rootModel as unknown as ApolloRootModel, handleClose: () => { doneCallback() }, @@ -74,7 +147,7 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { (doneCallback) => [ LogOut, { - session, + rootModel: rootModel as unknown as ApolloRootModel, handleClose: () => { doneCallback() }, From 979c9d74d257007b04ed3a7bdf9d10c0885c3f84 Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:17:59 -0400 Subject: [PATCH 09/31] Improve assembly management and organism metadata UX - add organism/scientific name support to assembly add/change flows - add Edit Assemblies admin panel and menu action - reorder Organism before Assembly in workspace/admin tables - harden plugin rebuild clean step to avoid mount-related apollo.js 404 Assisted-by: GitHub Copilot (GPT-5.3-Codex) --- .../src/assemblies/assemblies.controller.ts | 11 + .../src/assemblies/assemblies.service.ts | 33 +- .../src/assemblies/dto/create-assembly.dto.ts | 1 + .../src/changes/changeHandlers.service.ts | 36 +- .../apollo-schemas/src/assembly.schema.ts | 3 + .../AddAssemblyAndFeaturesFromFileChange.ts | 5 +- .../Changes/AddAssemblyFromExternalChange.ts | 11 +- .../src/Changes/AddAssemblyFromFileChange.ts | 5 +- packages/jbrowse-plugin-apollo/package.json | 2 +- .../src/components/AddAssembly.tsx | 22 + .../src/components/EditAssemblies.tsx | 260 +++++ .../src/components/ManageUsers.tsx | 942 +++++++++++++----- .../src/components/MyAssemblyPermissions.tsx | 13 + .../src/components/index.ts | 1 + .../manageUsersAssemblyPermissions.ts | 7 + .../src/menus/topLevelMenu.ts | 26 +- .../src/menus/topLevelMenuAdmin.ts | 19 + 17 files changed, 1119 insertions(+), 278 deletions(-) create mode 100644 packages/jbrowse-plugin-apollo/src/components/EditAssemblies.tsx diff --git a/packages/apollo-collaboration-server/src/assemblies/assemblies.controller.ts b/packages/apollo-collaboration-server/src/assemblies/assemblies.controller.ts index bc2abdd2a..ba8dac754 100644 --- a/packages/apollo-collaboration-server/src/assemblies/assemblies.controller.ts +++ b/packages/apollo-collaboration-server/src/assemblies/assemblies.controller.ts @@ -6,12 +6,14 @@ import { Logger, Param, Post, + Put, } from '@nestjs/common' import { Role } from '../utils/role/role.enum.js' import { Validations } from '../utils/validation/validatation.decorator.js' import { AssembliesService } from './assemblies.service.js' +import { UpdateAssemblyDto } from './dto/update-assembly.dto.js' interface AssemblyDocument { _id: string @@ -46,4 +48,13 @@ export class AssembliesController { findOne(@Param('id') id: string) { return this.assembliesService.findOne(id) } + + @Put(':id') + @Validations(Role.Admin) + update( + @Param('id') id: string, + @Body() updateAssemblyDto: UpdateAssemblyDto, + ) { + return this.assembliesService.update(id, updateAssemblyDto) + } } diff --git a/packages/apollo-collaboration-server/src/assemblies/assemblies.service.ts b/packages/apollo-collaboration-server/src/assemblies/assemblies.service.ts index e2a13e7f3..4ff2c3e5e 100644 --- a/packages/apollo-collaboration-server/src/assemblies/assemblies.service.ts +++ b/packages/apollo-collaboration-server/src/assemblies/assemblies.service.ts @@ -27,8 +27,19 @@ export class AssembliesService { private readonly logger = new Logger(AssembliesService.name) + private normalizeScientificName(scientificName?: string) { + const normalized = scientificName?.trim() + return normalized || undefined + } + async create(createAssemblyDto: CreateAssemblyDto) { - return this.assemblyModel.create(createAssemblyDto) + const scientificName = this.normalizeScientificName( + createAssemblyDto.scientificName, + ) + return this.assemblyModel.create({ + ...createAssemblyDto, + scientificName, + }) } async updateChecks(_id: string, checks: string[]) { @@ -82,10 +93,24 @@ export class AssembliesService { } update(id: string, updateAssemblyDto: UpdateAssemblyDto) { + const normalizedUpdateDto: Record = { + ...updateAssemblyDto, + } + if ('scientificName' in updateAssemblyDto) { + normalizedUpdateDto.scientificName = this.normalizeScientificName( + updateAssemblyDto.scientificName, + ) + } + return this.assemblyModel - .findByIdAndUpdate({ id, status: 0 }, updateAssemblyDto, { - runValidators: true, - }) + .findOneAndUpdate( + { _id: id, status: 0 }, + { $set: normalizedUpdateDto }, + { + runValidators: true, + new: true, + }, + ) .exec() } diff --git a/packages/apollo-collaboration-server/src/assemblies/dto/create-assembly.dto.ts b/packages/apollo-collaboration-server/src/assemblies/dto/create-assembly.dto.ts index 5263d41b3..928719ddd 100644 --- a/packages/apollo-collaboration-server/src/assemblies/dto/create-assembly.dto.ts +++ b/packages/apollo-collaboration-server/src/assemblies/dto/create-assembly.dto.ts @@ -1,6 +1,7 @@ export class CreateAssemblyDto { readonly name: string readonly displayName?: string + readonly scientificName?: string readonly description?: string readonly aliases?: string[] } diff --git a/packages/apollo-collaboration-server/src/changes/changeHandlers.service.ts b/packages/apollo-collaboration-server/src/changes/changeHandlers.service.ts index 04b6a7d5f..6cef99411 100644 --- a/packages/apollo-collaboration-server/src/changes/changeHandlers.service.ts +++ b/packages/apollo-collaboration-server/src/changes/changeHandlers.service.ts @@ -1146,6 +1146,7 @@ export class ChangeHandlersService implements ChangeHandlers { private async addAssemblyFromFileIndexed( assembly: string, assemblyName: string, + scientificName: string | undefined, fileIds: { fa: string; fai: string; gzi: string }, user: string, ): Promise { @@ -1184,8 +1185,17 @@ export class ChangeHandlersService implements ChangeHandlers { } const checkDocs = await checkModel.find({ isDefault: true }).exec() const checks = checkDocs.map((checkDoc) => checkDoc._id.toHexString()) + const normalizedScientificName = scientificName?.trim() || undefined await assemblyModel.create([ - { _id: assembly, name: assemblyName, user, status: -1, fileIds, checks }, + { + _id: assembly, + name: assemblyName, + scientificName: normalizedScientificName, + user, + status: -1, + fileIds, + checks, + }, ]) const accessGroup = await this.assemblyPermissionsService.ensureAssemblyAccessGroup( @@ -1224,6 +1234,7 @@ export class ChangeHandlersService implements ChangeHandlers { private async addAssemblyFromFileFasta( assembly: string, assemblyName: string, + scientificName: string | undefined, fileId: string, user: string, ): Promise { @@ -1241,10 +1252,12 @@ export class ChangeHandlersService implements ChangeHandlers { } const checkDocs = await checkModel.find({ default: true }).exec() const checks = checkDocs.map((checkDoc) => checkDoc._id.toHexString()) + const normalizedScientificName = scientificName?.trim() || undefined await assemblyModel.create([ { _id: assembly, name: assemblyName, + scientificName: normalizedScientificName, user, status: -1, fileIds: { fa: fileId }, @@ -1395,7 +1408,7 @@ export class ChangeHandlersService implements ChangeHandlers { const { CHUNK_SIZE } = process.env const customChunkSize = CHUNK_SIZE && Number(CHUNK_SIZE) for (const c of changes) { - const { assemblyName, externalLocation } = c + const { assemblyName, scientificName, externalLocation } = c const { fa, fai, gzi } = externalLocation const sequenceAdapter = gzi ? new BgzipIndexedFasta({ @@ -1419,10 +1432,12 @@ export class ChangeHandlersService implements ChangeHandlers { } const checkDocs = await checkModel.find({ default: true }).exec() const checks = checkDocs.map((checkDoc) => checkDoc._id.toHexString()) + const normalizedScientificName = scientificName?.trim() || undefined await assemblyModel.create([ { _id: assembly, name: assemblyName, + scientificName: normalizedScientificName, user, status: -1, externalLocation, @@ -1471,17 +1486,19 @@ export class ChangeHandlersService implements ChangeHandlers { const { changes } = change const { user } = context for (const c of changes) { - const { assemblyName, fileIds } = c + const { assemblyName, scientificName, fileIds } = c await ('gzi' in fileIds ? this.addAssemblyFromFileIndexed( change.assembly, assemblyName, + scientificName, fileIds, user, ) : this.addAssemblyFromFileFasta( change.assembly, assemblyName, + scientificName, fileIds.fa, user, )) @@ -1496,7 +1513,7 @@ export class ChangeHandlersService implements ChangeHandlers { const { assembly, changes } = change const { user } = context for (const c of changes) { - const { assemblyName, fileIds, parseOptions } = c + const { assemblyName, scientificName, fileIds, parseOptions } = c const fileId = fileIds.fa const { FILE_UPLOAD_FOLDER } = process.env if (!FILE_UPLOAD_FOLDER) { @@ -1515,8 +1532,17 @@ export class ChangeHandlersService implements ChangeHandlers { } const checkDocs = await checkModel.find({ isDefault: true }).exec() const checks = checkDocs.map((checkDoc) => checkDoc._id.toHexString()) + const normalizedScientificName = scientificName?.trim() || undefined await assemblyModel.create([ - { _id: assembly, name: assemblyName, user, status: -1, fileId, checks }, + { + _id: assembly, + name: assemblyName, + scientificName: normalizedScientificName, + user, + status: -1, + fileId, + checks, + }, ]) const accessGroup = await this.assemblyPermissionsService.ensureAssemblyAccessGroup( diff --git a/packages/apollo-schemas/src/assembly.schema.ts b/packages/apollo-schemas/src/assembly.schema.ts index cdce3d3da..c37de0b78 100644 --- a/packages/apollo-schemas/src/assembly.schema.ts +++ b/packages/apollo-schemas/src/assembly.schema.ts @@ -17,6 +17,9 @@ export class Assembly { @Prop() displayName: string + @Prop() + scientificName: string + @Prop({ type: [String] }) aliases: string[] diff --git a/packages/apollo-shared/src/Changes/AddAssemblyAndFeaturesFromFileChange.ts b/packages/apollo-shared/src/Changes/AddAssemblyAndFeaturesFromFileChange.ts index 4b7380266..e989a5887 100644 --- a/packages/apollo-shared/src/Changes/AddAssemblyAndFeaturesFromFileChange.ts +++ b/packages/apollo-shared/src/Changes/AddAssemblyAndFeaturesFromFileChange.ts @@ -11,6 +11,7 @@ export interface SerializedAddAssemblyAndFeaturesFromFileChangeBase export interface AddAssemblyAndFeaturesFromFileChangeDetails { assemblyName: string + scientificName?: string fileIds: { fa: string } parseOptions?: { bufferSize?: number; strict?: boolean } } @@ -47,8 +48,8 @@ export class AddAssemblyAndFeaturesFromFileChange extends AssemblySpecificChange toJSON(): SerializedAddAssemblyAndFeaturesFromFileChange { const { assembly, changes, typeName } = this if (changes.length === 1) { - const [{ assemblyName, fileIds }] = changes - return { typeName, assembly, assemblyName, fileIds } + const [{ assemblyName, scientificName, fileIds }] = changes + return { typeName, assembly, assemblyName, scientificName, fileIds } } return { typeName, assembly, changes } } diff --git a/packages/apollo-shared/src/Changes/AddAssemblyFromExternalChange.ts b/packages/apollo-shared/src/Changes/AddAssemblyFromExternalChange.ts index 55bbdc968..86e38598c 100644 --- a/packages/apollo-shared/src/Changes/AddAssemblyFromExternalChange.ts +++ b/packages/apollo-shared/src/Changes/AddAssemblyFromExternalChange.ts @@ -11,6 +11,7 @@ export interface SerializedAddAssemblyFromExternalChangeBase export interface AddAssemblyFromExternalChangeDetails { assemblyName: string + scientificName?: string externalLocation: { fa: string; fai: string; gzi?: string } } @@ -46,8 +47,14 @@ export class AddAssemblyFromExternalChange extends AssemblySpecificChange { toJSON(): SerializedAddAssemblyFromExternalChange { const { assembly, changes, typeName } = this if (changes.length === 1) { - const [{ assemblyName, externalLocation }] = changes - return { typeName, assembly, assemblyName, externalLocation } + const [{ assemblyName, scientificName, externalLocation }] = changes + return { + typeName, + assembly, + assemblyName, + scientificName, + externalLocation, + } } return { typeName, assembly, changes } } diff --git a/packages/apollo-shared/src/Changes/AddAssemblyFromFileChange.ts b/packages/apollo-shared/src/Changes/AddAssemblyFromFileChange.ts index 8a5409ed0..66293a4c8 100644 --- a/packages/apollo-shared/src/Changes/AddAssemblyFromFileChange.ts +++ b/packages/apollo-shared/src/Changes/AddAssemblyFromFileChange.ts @@ -11,6 +11,7 @@ export interface SerializedAddAssemblyFromFileChangeBase export interface AddAssemblyFromFileChangeDetails { assemblyName: string + scientificName?: string fileIds: { fa: string } | { fa: string; fai: string; gzi: string } } @@ -46,8 +47,8 @@ export class AddAssemblyFromFileChange extends AssemblySpecificChange { toJSON(): SerializedAddAssemblyFromFileChange { const { assembly, changes, typeName } = this if (changes.length === 1) { - const [{ assemblyName, fileIds }] = changes - return { typeName, assembly, assemblyName, fileIds } + const [{ assemblyName, scientificName, fileIds }] = changes + return { typeName, assembly, assemblyName, scientificName, fileIds } } return { typeName, assembly, changes } } diff --git a/packages/jbrowse-plugin-apollo/package.json b/packages/jbrowse-plugin-apollo/package.json index 6af558470..788024db7 100644 --- a/packages/jbrowse-plugin-apollo/package.json +++ b/packages/jbrowse-plugin-apollo/package.json @@ -20,7 +20,7 @@ ], "scripts": { "setup": "rimraf .jbrowse && jbrowse create .jbrowse", - "clean": "rimraf dist", + "clean": "mkdir -p dist && find dist -mindepth 1 -maxdepth 1 -exec rm -rf {} +", "start": "yarn clean && npm-run-all --parallel start:watch start:server", "start:watch": "JB_NPM=false NODE_ENV=development rollup --config rollup/rollup.config.mjs --watch", "start:server": "serve --no-request-logging --cors --listen 9000 --no-port-switching .", diff --git a/packages/jbrowse-plugin-apollo/src/components/AddAssembly.tsx b/packages/jbrowse-plugin-apollo/src/components/AddAssembly.tsx index 6cbca7833..daacceb2f 100644 --- a/packages/jbrowse-plugin-apollo/src/components/AddAssembly.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/AddAssembly.tsx @@ -134,6 +134,7 @@ export function AddAssembly({ throw new Error('No Apollo internet account found') } const [assemblyName, setAssemblyName] = useState('') + const [organismName, setOrganismName] = useState('') const [errorMessage, setErrorMessage] = useState('') const [validAsm, setValidAsm] = useState(false) const [fileType, setFileType] = useState(FileType.BGZIP_FASTA) @@ -249,12 +250,14 @@ export function AddAssembly({ | AddAssemblyFromExternalChange | AddAssemblyAndFeaturesFromFileChange | AddAssemblyFromFileChange + const scientificName = organismName.trim() || undefined if (fileType === FileType.EXTERNAL) { change = new AddAssemblyFromExternalChange({ typeName: 'AddAssemblyFromExternalChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, externalLocation: { fa: fastaUrl, fai: fastaIndexUrl, @@ -271,6 +274,7 @@ export function AddAssembly({ typeName: 'AddAssemblyAndFeaturesFromFileChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, fileIds: { fa: faId }, parseOptions: { strict }, }) @@ -280,6 +284,7 @@ export function AddAssembly({ typeName: 'AddAssemblyFromFileChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, fileIds: { fa: faId, }, @@ -290,6 +295,7 @@ export function AddAssembly({ typeName: 'AddAssemblyFromFileChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, fileIds: { fa: faId, }, @@ -306,6 +312,7 @@ export function AddAssembly({ typeName: 'AddAssemblyFromFileChange', assembly: new ObjectID().toHexString(), assemblyName, + scientificName, fileIds: { fa: faId, fai: faiId, @@ -387,6 +394,21 @@ export function AddAssembly({ disabled={submitted && !errorMessage} /> + { + setOrganismName(e.target.value) + }} + disabled={submitted && !errorMessage} + /> + (session) + const apolloInternetAccounts = internetAccounts + .filter((ia) => ia.type === 'ApolloInternetAccount') + .filter((ia) => (ia as ApolloInternetAccountModel & { role?: string }).role) + .filter((ia) => + ( + (ia as ApolloInternetAccountModel & { role?: string }).role ?? '' + ).includes('admin'), + ) as ApolloInternetAccountModel[] + + if (apolloInternetAccounts.length === 0) { + throw new Error('No Apollo admin internet account found') + } + + const [selectedInternetAccount, setSelectedInternetAccount] = useState( + apolloInternetAccounts[0], + ) + const [assemblies, setAssemblies] = useState([]) + const [selectedAssemblyId, setSelectedAssemblyId] = useState('') + const [displayName, setDisplayName] = useState('') + const [organismName, setOrganismName] = useState('') + const [errorMessage, setErrorMessage] = useState('') + const [isSaving, setIsSaving] = useState(false) + + useEffect(() => { + async function loadAssemblies() { + const { baseURL } = selectedInternetAccount + const uri = new URL('assemblies', baseURL).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when loading assemblies', + ) + throw new Error(newErrorMessage) + } + + const data = (await response.json()) as AssemblyResponse[] + const sorted = [...data].sort((a, b) => a.name.localeCompare(b.name)) + setAssemblies(sorted) + if (sorted.length > 0) { + const first = sorted[0] + setSelectedAssemblyId(first._id) + setDisplayName(first.displayName ?? first.name) + setOrganismName(first.scientificName ?? '') + } else { + setSelectedAssemblyId('') + setDisplayName('') + setOrganismName('') + } + } + + loadAssemblies().catch((error) => { + setErrorMessage(String(error)) + }) + }, [selectedInternetAccount]) + + const selectedAssembly = useMemo( + () => assemblies.find((assembly) => assembly._id === selectedAssemblyId), + [assemblies, selectedAssemblyId], + ) + + function handleChangeInternetAccount(e: SelectChangeEvent) { + const next = apolloInternetAccounts.find( + (ia) => ia.internetAccountId === e.target.value, + ) + if (!next) { + throw new Error( + `Could not find internet account with id "${e.target.value}"`, + ) + } + setSelectedInternetAccount(next) + setErrorMessage('') + } + + function handleChangeAssembly(e: SelectChangeEvent) { + const nextAssembly = assemblies.find((a) => a._id === e.target.value) + setSelectedAssemblyId(e.target.value) + setDisplayName(nextAssembly?.displayName ?? nextAssembly?.name ?? '') + setOrganismName(nextAssembly?.scientificName ?? '') + setErrorMessage('') + } + + async function handleSave() { + if (!selectedAssembly) { + setErrorMessage('Select an assembly first.') + return + } + + setIsSaving(true) + setErrorMessage('') + try { + const { baseURL } = selectedInternetAccount + const uri = new URL(`assemblies/${selectedAssembly._id}`, baseURL).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + displayName: displayName.trim() || selectedAssembly.name, + scientificName: organismName.trim() || undefined, + }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating assembly', + ) + throw new Error(newErrorMessage) + } + + const updated = (await response.json()) as AssemblyResponse + setAssemblies((prev) => + prev.map((assembly) => + assembly._id === updated._id ? { ...assembly, ...updated } : assembly, + ), + ) + setDisplayName(updated.displayName ?? updated.name) + setOrganismName(updated.scientificName ?? '') + } catch (error) { + setErrorMessage(String(error)) + } finally { + setIsSaving(false) + } + } + + return ( + + + {apolloInternetAccounts.length > 1 ? ( + <> + Select account + + + ) : null} + + + Select assembly + + + + + { + setDisplayName(e.target.value) + }} + disabled={!selectedAssembly || isSaving} + fullWidth + /> + + { + setOrganismName(e.target.value) + }} + disabled={!selectedAssembly || isSaving} + fullWidth + /> + + {errorMessage ? ( + {errorMessage} + ) : null} + + + + + + + + + ) +} diff --git a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx index b03e282ef..82d90753c 100644 --- a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx @@ -19,10 +19,10 @@ import { InputLabel, MenuItem, Select, + type SelectChangeEvent, Switch, Tab, Tabs, - type SelectChangeEvent, TextField, Typography, } from '@mui/material' @@ -91,10 +91,20 @@ interface GroupMembershipRow { isMember: boolean } +interface UserGroupMembershipRow { + id: string + groupId: string + groupName: string + genusSpecies: string + description: string + isMember: boolean +} + interface EffectiveAssemblyPermissionRow { id: string assemblyId: string assemblyName: string + genusSpecies: string canViewAnnotations: boolean canEditAnnotations: boolean source: 'none' | 'direct' | 'group' | 'mixed' @@ -109,11 +119,11 @@ interface EffectiveAssemblyPermissionResponse { source: 'direct' | 'group' | 'mixed' } -type PermissionView = - | 'effective' - | 'assembly' - | 'groupMemberships' - | 'groupPermissions' +type ManagementSection = 'users' | 'groups' + +type UserPermissionView = 'effective' | 'assembly' | 'userGroupMemberships' + +type GroupManagementView = 'memberships' | 'permissions' type GroupPermissionView = 'current' | 'edit' @@ -133,12 +143,12 @@ export function ManageUsers({ session, }: ManageUsersProps) { const { internetAccounts } = getRoot(session) - const apolloInternetAccounts: ApolloInternetAccountModel[] = internetAccounts - .filter(isApolloInternetAccount) + const apolloInternetAccounts = internetAccounts + .filter((ia) => isApolloInternetAccount(ia)) .filter( (ia: ApolloInternetAccountModel & { role?: string }) => ia.role?.includes('admin') ?? false, - ) + ) as ApolloInternetAccountModel[] if (apolloInternetAccounts.length === 0) { throw new Error('No Apollo internet account found') } @@ -152,8 +162,12 @@ export function ManageUsers({ const [groupFilterText, setGroupFilterText] = useState('') const [selectedUserId, setSelectedUserId] = useState('') const [selectedGroupId, setSelectedGroupId] = useState('') + const [managementSection, setManagementSection] = + useState('users') const [permissionView, setPermissionView] = - useState('effective') + useState('effective') + const [groupManagementView, setGroupManagementView] = + useState('memberships') const [groupMembershipView, setGroupMembershipView] = useState('edit') const [groupPermissionView, setGroupPermissionView] = @@ -167,6 +181,9 @@ export function ManageUsers({ const [groupMembershipByUserId, setGroupMembershipByUserId] = useState< Partial> >({}) + const [groupMembershipByGroupId, setGroupMembershipByGroupId] = useState< + Partial> + >({}) const [groupPermissionsByAssemblyId, setGroupPermissionsByAssemblyId] = useState>>({}) const [newGroupName, setNewGroupName] = useState('') @@ -321,6 +338,42 @@ export function ManageUsers({ }) }, [selectedInternetAccount, selectedUserId]) + useEffect(() => { + async function getMembershipsForUser() { + if (!selectedUserId) { + setGroupMembershipByGroupId({}) + return + } + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/memberships/byUser/${selectedUserId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when getting user group memberships from db', + ) + throw new Error(newErrorMessage) + } + const membershipData = + (await response.json()) as GroupMembershipResponse[] + const next: Partial> = {} + for (const membership of membershipData) { + next[membership.groupId] = true + } + setGroupMembershipByGroupId(next) + } + getMembershipsForUser().catch((error) => { + setErrorMessage(String(error)) + }) + }, [selectedInternetAccount, selectedUserId]) + useEffect(() => { async function getMembershipsForGroup() { if (!selectedGroupId) { @@ -733,6 +786,48 @@ export function ManageUsers({ } } + async function processUserGroupMembershipRowUpdate(newRow: GridRowModel) { + if (!selectedUserId) { + return newRow + } + const groupId = String(newRow.groupId) + const isMember = Boolean(newRow.isMember) + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/groups/memberships/${groupId}/${selectedUserId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ isMember }), + }) + + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when updating user group membership', + ) + throw new Error(newErrorMessage) + } + + setGroupMembershipByGroupId((prev: Partial>) => ({ + ...prev, + [groupId]: isMember, + })) + + return { + ...newRow, + isMember, + } + } + async function processGroupAssemblyPermissionRowUpdate(newRow: GridRowModel) { if (!selectedGroupId) { return newRow @@ -892,6 +987,99 @@ export function ManageUsers({ (group: GroupResponse) => group._id === selectedGroupId, ) + const assembliesByLookup = useMemo(() => { + const next = new Map() + for (const assembly of assemblies) { + next.set(assembly._id, assembly) + next.set(assembly.name, assembly) + next.set(assembly.name.toLowerCase(), assembly) + if (assembly.displayName) { + next.set(assembly.displayName, assembly) + next.set(assembly.displayName.toLowerCase(), assembly) + } + } + return next + }, [assemblies]) + + const defaultScientificName = useMemo(() => { + const counts = new Map() + for (const assembly of assemblies) { + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' + if (scientificName) { + counts.set(scientificName, (counts.get(scientificName) ?? 0) + 1) + } + } + const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]) + return ranked[0]?.[0] + }, [assemblies]) + + function findAssemblyForGroupToken(token?: string) { + if (!token) { + return + } + const cleanedToken = token.trim() + if (!cleanedToken) { + return + } + return ( + assembliesByLookup.get(cleanedToken) ?? + assembliesByLookup.get(cleanedToken.toLowerCase()) + ) + } + + function getGroupGenusSpecies(group: GroupResponse) { + const assemblyPrefix = 'assembly:' + if (group.name.startsWith(assemblyPrefix)) { + const assemblyToken = group.name.slice(assemblyPrefix.length).trim() + const assembly = findAssemblyForGroupToken(assemblyToken) + if (assembly) { + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' + if (scientificName.length > 0) { + return scientificName + } + } + return defaultScientificName || 'Unknown' + } + + const directAssemblyMatch = findAssemblyForGroupToken(group.name) + if (directAssemblyMatch) { + const scientificName = + typeof directAssemblyMatch.scientificName === 'string' + ? directAssemblyMatch.scientificName.trim() + : '' + if (scientificName.length > 0) { + return scientificName + } + return defaultScientificName || 'Unknown' + } + + const descriptionMatch = group.description?.match(/assembly\s+(.+)$/i) + const assemblyTokenFromDescription = descriptionMatch?.[1] + ?.replace(/[.,;:]$/, '') + .trim() + if (assemblyTokenFromDescription) { + const assembly = findAssemblyForGroupToken(assemblyTokenFromDescription) + if (assembly) { + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' + if (scientificName.length > 0) { + return scientificName + } + } + return defaultScientificName || 'Unknown' + } + + return defaultScientificName || 'Unknown' + } + const filteredGroups = useMemo(() => { const query = groupFilterText.trim().toLowerCase() if (!query) { @@ -926,13 +1114,19 @@ export function ManageUsers({ .map((assembly: AssemblyResponse) => { const permission = effectiveAssemblyPermissionsByAssemblyId[assembly._id] + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' return { id: assembly._id, assemblyId: assembly._id, assemblyName: assembly.displayName ?? assembly.name, + genusSpecies: scientificName || 'Unknown', canViewAnnotations: permission?.canViewAnnotations ?? false, canEditAnnotations: permission?.canEditAnnotations ?? false, - source: permission?.source ?? 'none', + source: (permission?.source ?? + 'none') as EffectiveAssemblyPermissionRow['source'], } }) .sort( @@ -954,6 +1148,19 @@ export function ManageUsers({ a.username.localeCompare(b.username), ) + const userGroupMembershipRows: UserGroupMembershipRow[] = groups + .map((group: GroupResponse) => ({ + id: group._id, + groupId: group._id, + groupName: group.name, + genusSpecies: getGroupGenusSpecies(group), + description: group.description ?? '', + isMember: Boolean(groupMembershipByGroupId[group._id]), + })) + .sort((a: UserGroupMembershipRow, b: UserGroupMembershipRow) => + a.groupName.localeCompare(b.groupName), + ) + const groupAssemblyPermissionRows: AssemblyPermissionRow[] = buildAssemblyPermissionRows(assemblies, groupPermissionsByAssemblyId).sort( (a: AssemblyPermissionRow, b: AssemblyPermissionRow) => @@ -974,6 +1181,13 @@ export function ManageUsers({ : 'Create or select a group to manage inherited access.' const assemblyPermissionColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, { field: 'assemblyName', headerName: 'Assembly', @@ -999,6 +1213,13 @@ export function ManageUsers({ ] const effectiveAssemblyPermissionColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, { field: 'assemblyName', headerName: 'Assembly', @@ -1023,12 +1244,14 @@ export function ManageUsers({ headerName: 'User', width: 180, editable: false, + filterable: true, }, { field: 'email', headerName: 'Email', width: 240, editable: false, + filterable: true, }, { field: 'isMember', @@ -1036,6 +1259,7 @@ export function ManageUsers({ width: 140, sortable: false, editable: false, + filterable: true, renderCell: (params: GridRenderCellParams) => ( ) => ( + ) => { + event.stopPropagation() + }} + onChange={( + _event: React.ChangeEvent, + checked: boolean, + ) => { + processUserGroupMembershipRowUpdate({ + ...params.row, + isMember: checked, + }).catch((error) => { + setErrorMessage(String(error)) + }) + }} + /> + ), + }, + ] + + const groupDirectoryColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, + { + field: 'name', + headerName: 'Group', + width: 260, + editable: false, + filterable: true, + }, + { + field: 'description', + headerName: 'Description', + width: 320, + editable: false, + filterable: true, + }, + ] + + const groupDirectoryRows = groups.map((group: GroupResponse) => ({ + id: group._id, + _id: group._id, + name: group.name, + genusSpecies: getGroupGenusSpecies(group), + description: group.description ?? '', + })) + const groupManagementControls = ( <> {selectableGroups.map((group: GroupResponse) => ( - {group.name} + {`${group.name} (${getGroupGenusSpecies(group)})`} ))} + +
+ { + setSelectedGroupId(String(params.id)) + }} + /> +
{selectedManagedGroup ? ( {`Managing ${selectedManagedGroup.name}: ${groupSummary}`} @@ -1167,11 +1496,19 @@ export function ManageUsers({ ) const groupAssemblyPermissionColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, { field: 'assemblyName', headerName: 'Assembly', width: 280, editable: false, + filterable: true, }, buildTogglePermissionColumn( 'canViewAnnotations', @@ -1192,11 +1529,19 @@ export function ManageUsers({ ] const groupAssemblyPermissionStateColumns: GridColDef[] = [ + { + field: 'genusSpecies', + headerName: 'Organism', + width: 220, + editable: false, + filterable: true, + }, { field: 'assemblyName', headerName: 'Assembly', width: 280, editable: false, + filterable: true, }, buildEffectivePermissionColumn('canViewAnnotations', 'View enabled'), buildEffectivePermissionColumn('canEditAnnotations', 'Edit enabled'), @@ -1245,288 +1590,383 @@ export function ManageUsers({ ) : null}
- - User roles - - - row._id} - slots={{ toolbar: GridToolbar }} - sx={apolloDataGridSx} - getRowHeight={() => 'auto'} - isCellEditable={(params: GridCellParams) => - !isCurrentUser(params.id) - } - processRowUpdate={processRowUpdate} - onProcessRowUpdateError={(error: unknown) => { - setErrorMessage(String(error)) - }} - /> -
- - - - Permission management - { - setPermissionView(value) + value={managementSection} + onChange={( + _event: React.SyntheticEvent, + value: ManagementSection, + ) => { + setManagementSection(value) }} sx={{ marginBottom: 2 }} > - - - - + + - {permissionView === 'effective' || permissionView === 'assembly' ? ( - - - Managed user - - - - ) : null} - - {permissionView === 'effective' ? ( - <> - {selectedManagedUser ? ( - - {`Effective access for ${selectedManagedUser.username}`} - - ) : null} -
- -
- - ) : null} - - {permissionView === 'assembly' ? ( - <> - {selectedManagedUser ? ( - - {`Editing direct user access for ${selectedManagedUser.username}`} - - ) : null} -
- { - setErrorMessage(String(error)) - }} - disableRowSelectionOnClick - /> -
- - ) : null} - - {permissionView === 'groupMemberships' ? ( + {managementSection === 'users' ? ( <> - - Group memberships - - {groupManagementControls} - - { - setGroupMembershipView(value) + - - - - - {groupMembershipView === 'current' ? ( - <> - - {selectedManagedGroup - ? `${enabledGroupMembershipRows.length} users are currently members of this group.` - : 'Select a group to review its current memberships.'} - + User roles + +
+ row._id} + slots={{ toolbar: GridToolbar }} + slotProps={{ toolbar: { showQuickFilter: true } }} + sx={apolloDataGridSx} + getRowHeight={() => 'auto'} + isCellEditable={(params: GridCellParams) => + !isCurrentUser(params.id) + } + processRowUpdate={processRowUpdate} + onProcessRowUpdateError={(error: unknown) => { + setErrorMessage(String(error)) + }} + /> - - Active group memberships - - {enabledGroupMembershipRows.length ? ( -
+ + + Permission management + + { + setPermissionView(value) + }} + sx={{ marginBottom: 2 }} + > + + + + + + + + Managed user + + + + + {permissionView === 'effective' ? ( + <> + {selectedManagedUser ? ( + + {`Effective access for ${selectedManagedUser.username}`} + + ) : null} +
- ) : ( - - No users are currently members of this group. - - )} - - ) : null} - - {groupMembershipView === 'edit' ? ( - <> - - Toggle group membership here. Changes are saved row by row. - - - - Group memberships - -
- { - setErrorMessage(String(error)) - }} - disableRowSelectionOnClick - isCellEditable={(params: GridCellParams) => - Boolean(selectedGroupId) && params.field === 'isMember' - } - /> -
- - ) : null} + + ) : null} + + {permissionView === 'assembly' ? ( + <> + {selectedManagedUser ? ( + + {`Editing direct user access for ${selectedManagedUser.username}`} + + ) : null} +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + /> +
+ + ) : null} + + {permissionView === 'userGroupMemberships' ? ( + <> + {selectedManagedUser ? ( + + {`Toggle group membership for ${selectedManagedUser.username}. Changes are saved row by row.`} + + ) : null} +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + isCellEditable={(params: GridCellParams) => + Boolean(selectedUserId) && params.field === 'isMember' + } + /> +
+ + ) : null} +
) : null} - {permissionView === 'groupPermissions' ? ( + {managementSection === 'groups' ? ( <> - - Group permissions + + Group management - {groupManagementControls} - { - setGroupPermissionView(value) + setGroupManagementView(value) }} sx={{ marginBottom: 2 }} > - - + + - {groupPermissionView === 'current' ? ( + {groupManagementControls} + + {groupManagementView === 'memberships' ? ( <> - - {selectedManagedGroup - ? `${enabledGroupAssemblyPermissionRows.length} assemblies currently have inherited access in this group.` - : 'Select a group to review its current assembly permissions.'} + + Group memberships - - Enabled assembly permissions - - {enabledGroupAssemblyPermissionRows.length ? ( -
- -
- ) : ( - - No assembly permissions are currently enabled for this - group. - - )} + { + setGroupMembershipView(value) + }} + sx={{ marginBottom: 2 }} + > + + + + + {groupMembershipView === 'current' ? ( + <> + + {selectedManagedGroup + ? `${enabledGroupMembershipRows.length} users are currently members of this group.` + : 'Select a group to review its current memberships.'} + + + + Active group memberships + + {enabledGroupMembershipRows.length > 0 ? ( +
+ +
+ ) : ( + + No users are currently members of this group. + + )} + + ) : null} + + {groupMembershipView === 'edit' ? ( + <> + + Toggle group membership here. Changes are saved row by + row. + + + + Group memberships + +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + isCellEditable={(params: GridCellParams) => + Boolean(selectedGroupId) && + params.field === 'isMember' + } + /> +
+ + ) : null} ) : null} - {groupPermissionView === 'edit' ? ( + {groupManagementView === 'permissions' ? ( <> - - Toggle group assembly access here. Changes are saved row by - row. + + Group permissions - - Group assembly permissions - -
- { - setErrorMessage(String(error)) - }} - disableRowSelectionOnClick - isCellEditable={(params: GridCellParams) => - Boolean(selectedGroupId) && - (params.field === 'canViewAnnotations' || - params.field === 'canEditAnnotations') - } - /> -
+ { + setGroupPermissionView(value) + }} + sx={{ marginBottom: 2 }} + > + + + + + {groupPermissionView === 'current' ? ( + <> + + {selectedManagedGroup + ? `${enabledGroupAssemblyPermissionRows.length} assemblies currently have inherited access in this group.` + : 'Select a group to review its current assembly permissions.'} + + + + Enabled assembly permissions + + {enabledGroupAssemblyPermissionRows.length > 0 ? ( +
+ +
+ ) : ( + + No assembly permissions are currently enabled for this + group. + + )} + + ) : null} + + {groupPermissionView === 'edit' ? ( + <> + + Toggle group assembly access here. Changes are saved row + by row. + + + + Group assembly permissions + +
+ { + setErrorMessage(String(error)) + }} + disableRowSelectionOnClick + isCellEditable={(params: GridCellParams) => + Boolean(selectedGroupId) && + (params.field === 'canViewAnnotations' || + params.field === 'canEditAnnotations') + } + /> +
+ + ) : null} ) : null} ) : null} - +
), }, + { + field: 'genusSpecies', + headerName: 'Organism', + flex: 1, + minWidth: 220, + }, { field: 'assemblyName', headerName: 'Assembly', diff --git a/packages/jbrowse-plugin-apollo/src/components/index.ts b/packages/jbrowse-plugin-apollo/src/components/index.ts index 6a0f53997..17a521369 100644 --- a/packages/jbrowse-plugin-apollo/src/components/index.ts +++ b/packages/jbrowse-plugin-apollo/src/components/index.ts @@ -5,6 +5,7 @@ export * from './AddFeature' export * from './ColorFeature' export * from './CopyFeature' export * from './DeleteAssembly' +export * from './EditAssemblies' export * from './DeleteFeature' export * from './DownloadGFF3' export * from './ImportFeatures' diff --git a/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts b/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts index bc2a2170d..a6675a8d0 100644 --- a/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts +++ b/packages/jbrowse-plugin-apollo/src/components/manageUsersAssemblyPermissions.ts @@ -2,6 +2,7 @@ export interface AssemblyResponse { _id: string name: string displayName?: string + scientificName?: string } export interface AssemblyPermissionResponse { @@ -22,6 +23,7 @@ export interface AssemblyPermissionRow { id: string assemblyId: string assemblyName: string + genusSpecies: string canViewAnnotations: boolean canEditAnnotations: boolean } @@ -57,10 +59,15 @@ export function buildAssemblyPermissionRows( ): AssemblyPermissionRow[] { return assemblies.map((assembly) => { const permission = permissionsByAssemblyId[assembly._id] + const scientificName = + typeof assembly.scientificName === 'string' + ? assembly.scientificName.trim() + : '' return { id: assembly._id, assemblyId: assembly._id, assemblyName: assembly.displayName ?? assembly.name, + genusSpecies: scientificName || 'Unknown', canViewAnnotations: permission?.canViewAnnotations ?? false, canEditAnnotations: permission?.canEditAnnotations ?? false, } diff --git a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts index 02d71bed0..815339240 100644 --- a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts +++ b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts @@ -108,18 +108,22 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { isApolloInternetAccount(ia), ) if (hasApolloInternetAccount) { - rootModel.appendToMenu('Apollo', { - label: React.createElement(ApolloUserMenuLabel, { - rootModel: rootModel as unknown as ApolloRootModel, - }), - icon: AccountCircleIcon, - onClick: (session: ApolloSessionModel) => { - notifyCurrentApolloUser( - rootModel as unknown as ApolloRootModel, - session, - ) + rootModel.insertInMenu( + 'Apollo', + { + label: React.createElement(ApolloUserMenuLabel, { + rootModel: rootModel as unknown as ApolloRootModel, + }), + icon: AccountCircleIcon, + onClick: (session: ApolloSessionModel) => { + notifyCurrentApolloUser( + rootModel as unknown as ApolloRootModel, + session, + ) + }, }, - }) + 0, + ) rootModel.appendToMenu('Apollo', { label: 'My workspace', diff --git a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenuAdmin.ts b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenuAdmin.ts index 1f5ef4764..83997820d 100644 --- a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenuAdmin.ts +++ b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenuAdmin.ts @@ -5,6 +5,7 @@ import type { import AddIcon from '@mui/icons-material/Add' import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings' import DeleteIcon from '@mui/icons-material/Delete' +import EditIcon from '@mui/icons-material/Edit' import InputIcon from '@mui/icons-material/Input' import PersonIcon from '@mui/icons-material/Person' import RuleIcon from '@mui/icons-material/Rule' @@ -14,6 +15,7 @@ import { AddAssemblyAliases, AddRefSeqAliases, DeleteAssembly, + EditAssemblies, ImportFeatures, ManageChecks, ManageUsers, @@ -62,6 +64,23 @@ export function addTopLevelAdminMenus(rootModel: AbstractMenuManager) { ) }, }, + { + label: 'Edit Assemblies', + icon: EditIcon, + onClick: (session: ApolloSessionModel) => { + ;(session as unknown as AbstractSessionModel).queueDialog( + (doneCallback) => [ + EditAssemblies, + { + session, + handleClose: () => { + doneCallback() + }, + }, + ], + ) + }, + }, { label: 'Import Features', icon: InputIcon, From 400c7876e92d97e98681c583392f62eee006a09d Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Tue, 9 Jun 2026 14:43:17 -0400 Subject: [PATCH 10/31] Add manual GHCR registry access smoke workflow Assisted-by: GitHub Copilot (GPT-5.3-Codex) --- .../workflows/ghcr_registry_access_smoke.yml | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 .github/workflows/ghcr_registry_access_smoke.yml diff --git a/.github/workflows/ghcr_registry_access_smoke.yml b/.github/workflows/ghcr_registry_access_smoke.yml new file mode 100644 index 000000000..1b3fd950a --- /dev/null +++ b/.github/workflows/ghcr_registry_access_smoke.yml @@ -0,0 +1,68 @@ +name: USDA GHCR registry smoke test + +on: + workflow_dispatch: + inputs: + image_tag_prefix: + description: 'Prefix for the test image tag' + required: true + default: 'registry-access-test' + type: string + +permissions: + contents: read + packages: write + +env: + SMOKE_IMAGE: ghcr.io/usda-ree-ars/ghcr-access-smoke + +jobs: + push-smoke-image: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Set image tag + id: tag + run: | + TAG="${{ inputs.image_tag_prefix }}-${{ github.run_number }}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Create tiny smoke Dockerfile + run: | + cat > Dockerfile.ghcr-smoke <<'EOF' + FROM public.ecr.aws/docker/library/alpine:3.20 + RUN echo "usda ghcr smoke test" > /smoke.txt + CMD ["cat", "/smoke.txt"] + EOF + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push smoke image + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile.ghcr-smoke + platforms: linux/amd64 + push: true + tags: ${{ env.SMOKE_IMAGE }}:${{ steps.tag.outputs.tag }} + + - name: Verify pushed image + run: | + docker buildx imagetools inspect "${{ env.SMOKE_IMAGE }}:${{ steps.tag.outputs.tag }}" + + - name: Write run summary + run: | + { + echo "## USDA GHCR smoke test" + echo + echo "Pushed image:" + echo + echo "- \`${{ env.SMOKE_IMAGE }}:${{ steps.tag.outputs.tag }}\`" + } >> "$GITHUB_STEP_SUMMARY" From 91820fd88c33626b56f1850140b69528d7934eeb Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:22:18 -0400 Subject: [PATCH 11/31] Harden Apollo auth and session handling Assisted-by: GitHub Copilot (GPT-5.3-Codex) --- .../src/ApolloInternetAccount/model.ts | 12 +- .../src/components/LogOut.tsx | 32 ++-- .../src/components/MyAssemblyPermissions.tsx | 14 +- .../src/menus/topLevelMenu.ts | 149 +++++++++++++++--- .../src/session/session.ts | 15 +- packages/jbrowse-plugin-apollo/src/types.ts | 10 +- 6 files changed, 193 insertions(+), 39 deletions(-) diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts index 03d9806d4..289a236fa 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts @@ -61,7 +61,17 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { }) .views((self) => ({ get baseURL(): string { - return getConf(self, 'baseURL') + const configuredBaseURL = String(getConf(self, 'baseURL') ?? '') + if (!configuredBaseURL) { + return globalThis.location?.origin || 'http://localhost' + } + try { + const fallbackBase = globalThis.location?.origin || 'http://localhost' + const resolved = new URL(configuredBaseURL, fallbackBase).href + return resolved.endsWith('/') ? resolved : `${resolved}/` + } catch { + return configuredBaseURL + } }, getUserId() { const token = self.retrieveToken() diff --git a/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx b/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx index aa0ddef06..29f9fc584 100644 --- a/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx @@ -7,10 +7,11 @@ import { Select, type SelectChangeEvent, } from '@mui/material' +import { isAlive } from '@jbrowse/mobx-state-tree' import React, { useState } from 'react' import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' -import type { ApolloRootModel } from '../types' +import { type ApolloRootModel, isApolloInternetAccount } from '../types' import { Dialog } from './Dialog' @@ -22,12 +23,21 @@ interface DeleteAssemblyProps { export function LogOut({ handleClose, rootModel }: DeleteAssemblyProps) { const { internetAccounts } = rootModel const [errorMessage, setErrorMessage] = useState('') - const apolloInternetAccounts = internetAccounts.filter( - (ia) => ia.type === 'ApolloInternetAccount', - ) as ApolloInternetAccountModel[] + const apolloInternetAccounts = internetAccounts.filter((account) => { + try { + if (!isAlive(account)) { + return false + } + return isApolloInternetAccount(account) + } catch { + return false + } + }) as ApolloInternetAccountModel[] + if (apolloInternetAccounts.length === 0) { - throw new Error('No Apollo internet account found') + return null } + const [selectedInternetAccount, setSelectedInternetAccount] = useState( apolloInternetAccounts[0], ) @@ -47,8 +57,12 @@ export function LogOut({ handleClose, rootModel }: DeleteAssemblyProps) { function onSubmit(event: React.FormEvent) { event.preventDefault() setErrorMessage('') - selectedInternetAccount.removeToken() - globalThis.location.reload() + try { + selectedInternetAccount.removeToken() + globalThis.location.reload() + } catch { + setErrorMessage('Could not log out from this account. Please retry.') + } } return ( @@ -68,7 +82,7 @@ export function LogOut({ handleClose, rootModel }: DeleteAssemblyProps) { value={selectedInternetAccount.internetAccountId} onChange={handleChangeInternetAccount} > - {internetAccounts.map((option) => ( + {apolloInternetAccounts.map((option) => ( {option.name} @@ -89,7 +103,7 @@ export function LogOut({ handleClose, rootModel }: DeleteAssemblyProps) { > Log Out - diff --git a/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx index d9ad312c3..046234faf 100644 --- a/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx @@ -64,14 +64,26 @@ function wait(ms: number) { }) } +function safeRetrieveToken(account: ApolloInternetAccountModel) { + try { + if (!isAlive(account)) { + return undefined + } + return account.retrieveToken() || undefined + } catch { + return undefined + } +} + export function MyAssemblyPermissions({ handleClose, rootModel, }: MyAssemblyPermissionsProps) { const { internetAccounts } = rootModel const apolloInternetAccounts: ApolloInternetAccountModel[] = internetAccounts + .filter((ia) => isAlive(ia)) .filter(isApolloInternetAccount) - .filter((ia) => Boolean(ia.retrieveToken())) + .filter((ia) => Boolean(safeRetrieveToken(ia))) const [selectedInternetAccount] = useState(apolloInternetAccounts[0]) const [rows, setRows] = useState([]) const [errorMessage, setErrorMessage] = useState('') diff --git a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts index 815339240..50f310fb2 100644 --- a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts +++ b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts @@ -3,6 +3,7 @@ import type { AbstractSessionModel, } from '@jbrowse/core/util' import { getDecodedToken } from '@apollo-annotation/shared' +import { isAlive } from '@jbrowse/mobx-state-tree' import AccountCircleIcon from '@mui/icons-material/AccountCircle' import FactCheckIcon from '@mui/icons-material/FactCheck' import LogoutIcon from '@mui/icons-material/Logout' @@ -13,23 +14,79 @@ import React from 'react' import { LogOut, MyAssemblyPermissions } from '../components' import type { ApolloSessionModel } from '../session' import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' -import { type ApolloRootModel, isApolloInternetAccount } from '../types' +import type { ApolloRootModel } from '../types' -function getSignedInApolloAccount(rootModel: ApolloRootModel) { +function isUsableApolloAccount( + account: unknown, +): account is ApolloInternetAccountModel { + if (!account) { + return false + } + const maybeAccount = account as { + getToken?: unknown + removeToken?: unknown + internetAccountId?: unknown + } + return ( + typeof maybeAccount.getToken === 'function' && + typeof maybeAccount.removeToken === 'function' && + typeof maybeAccount.internetAccountId === 'string' + ) +} + +function getApolloInternetAccounts(rootModel: ApolloRootModel) { const { internetAccounts } = rootModel - return internetAccounts - .filter(isApolloInternetAccount) - .find((ia) => Boolean(ia.retrieveToken())) as - | ApolloInternetAccountModel - | undefined + return internetAccounts.filter((account) => { + try { + if (!isAlive(account)) { + return false + } + return isUsableApolloAccount(account) + } catch { + return false + } + }) as ApolloInternetAccountModel[] +} + +function getStoredToken(account?: ApolloInternetAccountModel) { + if (!account) { + return undefined + } + try { + if (!isAlive(account)) { + return undefined + } + const accountWithTokenKey = account as ApolloInternetAccountModel & { + tokenKey?: string + } + if (!accountWithTokenKey.tokenKey) { + return undefined + } + return globalThis.sessionStorage + .getItem(accountWithTokenKey.tokenKey) + ?.trim() + } catch { + return undefined + } +} + +function getSignedInApolloAccount(rootModel: ApolloRootModel) { + return getApolloInternetAccounts(rootModel).find((account) => + Boolean(getStoredToken(account)), + ) +} + +function getDefaultApolloAccount(rootModel: ApolloRootModel) { + return getApolloInternetAccounts(rootModel).find(Boolean) +} + +function isApolloSignedIn(rootModel: ApolloRootModel) { + return Boolean(getStoredToken(getSignedInApolloAccount(rootModel))) } function getCurrentApolloUserLabel(rootModel: ApolloRootModel) { const signedInAccount = getSignedInApolloAccount(rootModel) - if (!signedInAccount) { - return 'Signed in as: not signed in' - } - const token = signedInAccount.retrieveToken() + const token = getStoredToken(signedInAccount) if (!token) { return 'Signed in as: not signed in' } @@ -46,17 +103,17 @@ function ApolloUserMenuLabel({ rootModel }: { rootModel: ApolloRootModel }) { return getCurrentApolloUserLabel(rootModel) } +function ApolloAuthMenuLabel({ rootModel }: { rootModel: ApolloRootModel }) { + return isApolloSignedIn(rootModel) ? 'Log out' : 'Log in' +} + function notifyCurrentApolloUser( rootModel: ApolloRootModel, session: ApolloSessionModel, ) { const signedInAccount = getSignedInApolloAccount(rootModel) const sessionModel = session as unknown as AbstractSessionModel - if (!signedInAccount) { - sessionModel.notify('Apollo login status: not signed in', 'warning') - return - } - const token = signedInAccount.retrieveToken() + const token = getStoredToken(signedInAccount) if (!token) { sessionModel.notify('Apollo login status: not signed in', 'warning') return @@ -103,10 +160,9 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { session.toggleLocked() }, }) - const { internetAccounts } = rootModel as unknown as ApolloRootModel - const hasApolloInternetAccount = internetAccounts.some((ia) => - isApolloInternetAccount(ia), - ) + const hasApolloInternetAccount = + getApolloInternetAccounts(rootModel as unknown as ApolloRootModel).length > + 0 if (hasApolloInternetAccount) { rootModel.insertInMenu( 'Apollo', @@ -144,20 +200,61 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { }) rootModel.appendToMenu('Apollo', { - label: 'Log out', + label: React.createElement(ApolloAuthMenuLabel, { + rootModel: rootModel as unknown as ApolloRootModel, + }), icon: LogoutIcon, onClick: (session: ApolloSessionModel) => { - ;(session as unknown as AbstractSessionModel).queueDialog( - (doneCallback) => [ + const apolloRootModel = rootModel as unknown as ApolloRootModel + const sessionModel = session as unknown as AbstractSessionModel + if (isApolloSignedIn(apolloRootModel)) { + sessionModel.queueDialog((doneCallback) => [ LogOut, { - rootModel: rootModel as unknown as ApolloRootModel, + rootModel: apolloRootModel, handleClose: () => { doneCallback() }, }, - ], - ) + ]) + return + } + + const defaultAccount = getDefaultApolloAccount(apolloRootModel) + if (!defaultAccount) { + sessionModel.notify( + 'Apollo login is unavailable: no Apollo account configured', + 'error', + ) + return + } + + void defaultAccount + .getToken() + .then((token) => { + try { + const { username, email, role } = getDecodedToken(token) + const identity = username || email || 'unknown user' + const roleText = role ? ` (${role})` : '' + sessionModel.notify( + `Apollo login successful: ${identity}${roleText}`, + 'success', + ) + } catch { + sessionModel.notify('Apollo login successful', 'success') + } + }) + .catch((error: unknown) => { + if ( + error instanceof Error && + /user cancelled entry/i.test(error.message) + ) { + return + } + const message = + error instanceof Error ? error.message : 'Apollo login failed' + sessionModel.notify(message, 'error') + }) }, }) } diff --git a/packages/jbrowse-plugin-apollo/src/session/session.ts b/packages/jbrowse-plugin-apollo/src/session/session.ts index dcee60413..d1e444a57 100644 --- a/packages/jbrowse-plugin-apollo/src/session/session.ts +++ b/packages/jbrowse-plugin-apollo/src/session/session.ts @@ -29,6 +29,7 @@ import { applySnapshot, getRoot, getSnapshot, + isAlive, types, } from '@jbrowse/mobx-state-tree' import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view' @@ -46,6 +47,17 @@ import { clientDataStoreFactory, } from './ClientDataStore' +function safeRetrieveToken(account: ApolloInternetAccountModel) { + try { + if (!isAlive(account)) { + return undefined + } + return account.retrieveToken() || undefined + } catch { + return undefined + } +} + export interface ApolloSession extends AbstractSessionModel { apolloDataStore: ClientDataStoreModel apolloSelectedFeature?: AnnotationFeature @@ -343,11 +355,12 @@ export function extendSession( ) // Re-run this autorun after login/logout so role-gated config can be reloaded. const apolloAuthSignature = internetAccounts + .filter((ia) => isAlive(ia)) .filter(isApolloInternetAccount) .map( (ia) => `${ia.internetAccountId}:${ia.role ?? ''}:${Boolean( - ia.retrieveToken(), + safeRetrieveToken(ia), )}`, ) .join('|') diff --git a/packages/jbrowse-plugin-apollo/src/types.ts b/packages/jbrowse-plugin-apollo/src/types.ts index 37fd6e1d7..cd8ab9daa 100644 --- a/packages/jbrowse-plugin-apollo/src/types.ts +++ b/packages/jbrowse-plugin-apollo/src/types.ts @@ -1,5 +1,6 @@ import type { BaseInternetAccountModel } from '@jbrowse/core/pluggableElementTypes' import type { AppRootModel } from '@jbrowse/core/util' +import { isAlive } from '@jbrowse/mobx-state-tree' import type { ApolloInternetAccountModel } from './ApolloInternetAccount/model' import type { ApolloSessionModel } from './session' @@ -12,5 +13,12 @@ export interface ApolloRootModel extends Omit { export function isApolloInternetAccount( internetAccount: BaseInternetAccountModel | ApolloInternetAccountModel, ): internetAccount is ApolloInternetAccountModel { - return internetAccount.type === 'ApolloInternetAccount' + try { + if (!isAlive(internetAccount)) { + return false + } + return internetAccount.type === 'ApolloInternetAccount' + } catch { + return false + } } From e55d82261c21c81db7189a8eb7853d18de078a45 Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:10:47 -0400 Subject: [PATCH 12/31] Harden auth/session UX and permission refresh flows - revoke other Apollo tokens on successful login\n- normalize guest role to readOnly and enforce submit guard\n- add explicit login/logout menu actions with guest transition\n- refresh effective access after permission/group membership edits\n- add Replace/Add view semantics in My Workspace\n- document Node/Jest compatibility and new auth/access behaviors\n\nAssisted-by: GitHub Copilot (GPT-5.3-Codex) --- packages/jbrowse-plugin-apollo/README.md | 41 ++++ packages/jbrowse-plugin-apollo/package.json | 5 +- .../components/AuthTypeSelector.tsx | 17 +- .../src/ApolloInternetAccount/model.ts | 64 +++++- .../ApolloInternetAccount/tokenUtils.test.ts | 20 ++ .../src/ApolloInternetAccount/tokenUtils.ts | 15 ++ .../src/ChangeManager.ts | 41 ++++ .../LinearApolloDisplay/stateModel/base.ts | 8 +- .../stateModel/base.ts | 8 +- .../src/components/LogOut.tsx | 87 +++++++- .../src/components/ManageUsers.tsx | 66 +++--- .../src/components/MyAssemblyPermissions.tsx | 208 +++++++++++++++--- .../src/menus/topLevelMenu.ts | 108 +++++---- .../src/session/session.ts | 143 +++++++++++- 14 files changed, 703 insertions(+), 128 deletions(-) create mode 100644 packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.test.ts create mode 100644 packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts diff --git a/packages/jbrowse-plugin-apollo/README.md b/packages/jbrowse-plugin-apollo/README.md index 82e44cc23..135687fe7 100644 --- a/packages/jbrowse-plugin-apollo/README.md +++ b/packages/jbrowse-plugin-apollo/README.md @@ -17,6 +17,47 @@ 4. Retry with valid credentials. 5. Confirm login succeeds and menu label updates to signed-in identity. +## Auth and access behavior updates (2026-06-11) + +- Successful Apollo login now revokes tokens from other Apollo internet accounts + in the same session. +- Apollo top-level menu now has explicit `Log in` and `Log out` actions. +- Log out now transitions directly to guest auth without a full page reload. +- Guest users are normalized to `readOnly` role and cannot submit changes. + +## Permission management updates (2026-06-11) + +- `Manage users -> Effective access` now refreshes immediately after: + - direct user assembly permission changes, + - user group membership changes, + - group membership changes affecting the currently selected user, + - group assembly permission changes when the selected user is a member. +- `My workspace` now supports: + - `Replace` to reset open views and load the selected assembly in one fresh + view, + - `Add view` to open the selected assembly in an additional view. + +## Unit test runtime (Node) + +- Apollo plugin Jest tooling is not compatible with Node 26+. +- The package `test` and `test:ci` scripts now run a Node version check first. +- On unsupported versions, the command fails fast with: + `Apollo plugin Jest tooling is not compatible with Node . Use Node 20 or 22.` + +Run plugin tests with Node 22 (without changing your global Node): + +```bash +cd repos/Apollo3/packages/jbrowse-plugin-apollo +JEST_BIN=$(npx -y node@22 $(which yarn) bin jest) +npx -y node@22 $(which yarn) node --experimental-vm-modules "$JEST_BIN" src/ApolloInternetAccount/tokenUtils.test.ts +``` + +If you have Node 20 or 22 active in your shell, the normal command also works: + +```bash +yarn --cwd repos/Apollo3/packages/jbrowse-plugin-apollo test +``` + ## Testing with cypress These notes setup cypress and run tests. These notes are likely to change. diff --git a/packages/jbrowse-plugin-apollo/package.json b/packages/jbrowse-plugin-apollo/package.json index 788024db7..a711e3f71 100644 --- a/packages/jbrowse-plugin-apollo/package.json +++ b/packages/jbrowse-plugin-apollo/package.json @@ -19,6 +19,7 @@ "src" ], "scripts": { + "check:node": "node -e \"const major=Number(process.versions.node.split('.')[0]); if (major>=26){console.error('Apollo plugin Jest tooling is not compatible with Node '+process.versions.node+'. Use Node 20 or 22.'); process.exit(1)} console.log('Node version OK for plugin tests:', process.versions.node)\"", "setup": "rimraf .jbrowse && jbrowse create .jbrowse", "clean": "mkdir -p dist && find dist -mindepth 1 -maxdepth 1 -exec rm -rf {} +", "start": "yarn clean && npm-run-all --parallel start:watch start:server", @@ -26,8 +27,8 @@ "start:server": "serve --no-request-logging --cors --listen 9000 --no-port-switching .", "build": "yarn build:shared && yarn clean && rollup --config rollup/rollup.config.mjs", "browse": "serve --no-request-logging --listen 8999 --no-port-switching --symlinks .jbrowse", - "test": "yarn node --experimental-vm-modules $(yarn bin jest)", - "test:ci": "yarn node --experimental-vm-modules $(yarn bin jest) --coverage", + "test": "yarn check:node && yarn node --experimental-vm-modules $(yarn bin jest)", + "test:ci": "yarn check:node && yarn node --experimental-vm-modules $(yarn bin jest) --coverage", "start:collab-cypress": "yarn workspace @apollo-annotation/collaboration-server run cypress:start", "test:e2e": "yarn build && start-test \"npm-run-all --parallel start:server browse start:collab-cypress\" \"9000|8999|http://localhost:3999/health\" \"npm-run-all cypress:run\"", "test:e2e:debug": "yarn build && start-test \"npm-run-all --parallel start:server browse start:collab-cypress\" \"9000|8999|http://localhost:3999/health\" \"npm-run-all cypress:debug\"", diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx index 56ccedff3..4c5f3b3f6 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx @@ -10,7 +10,7 @@ import { Divider, TextField, } from '@mui/material' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { Dialog } from '../../components/Dialog' import { createFetchErrorMessage } from '../../util' @@ -33,9 +33,11 @@ export const AuthTypeSelector = ({ baseURL, handleClose, name, + preferGuest, }: { baseURL: string name: string + preferGuest?: boolean handleClose: ( type?: | 'google' @@ -52,6 +54,7 @@ export const AuthTypeSelector = ({ const [identifier, setIdentifier] = useState('') const [password, setPassword] = useState('') const [isSubmittingLocal, setIsSubmittingLocal] = useState(false) + const didAutoSelectGuest = useRef(false) useEffect(() => { const controller = new AbortController() const { signal } = controller @@ -68,6 +71,14 @@ export const AuthTypeSelector = ({ } const data = (await response.json()) as string[] setLoginTypes(data) + if ( + preferGuest && + data.includes('guest') && + !didAutoSelectGuest.current + ) { + didAutoSelectGuest.current = true + handleClose('guest') + } } getAuthTypes().catch((error) => { if (!isAbortException(error)) { @@ -82,7 +93,7 @@ export const AuthTypeSelector = ({ ), ) } - }, [baseURL]) + }, [baseURL, preferGuest]) function handleClick( authType: 'google' | 'microsoft' | 'logingov' | 'guest', @@ -208,6 +219,8 @@ export const AuthTypeSelector = ({ + '&:hover': { + boxShadow: 'none', + }, + }} + > + Replace + + +
), }, { diff --git a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts index 50f310fb2..2e9b9500e 100644 --- a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts +++ b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts @@ -14,25 +14,7 @@ import React from 'react' import { LogOut, MyAssemblyPermissions } from '../components' import type { ApolloSessionModel } from '../session' import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' -import type { ApolloRootModel } from '../types' - -function isUsableApolloAccount( - account: unknown, -): account is ApolloInternetAccountModel { - if (!account) { - return false - } - const maybeAccount = account as { - getToken?: unknown - removeToken?: unknown - internetAccountId?: unknown - } - return ( - typeof maybeAccount.getToken === 'function' && - typeof maybeAccount.removeToken === 'function' && - typeof maybeAccount.internetAccountId === 'string' - ) -} +import { isApolloInternetAccount, type ApolloRootModel } from '../types' function getApolloInternetAccounts(rootModel: ApolloRootModel) { const { internetAccounts } = rootModel @@ -41,7 +23,7 @@ function getApolloInternetAccounts(rootModel: ApolloRootModel) { if (!isAlive(account)) { return false } - return isUsableApolloAccount(account) + return isApolloInternetAccount(account) } catch { return false } @@ -70,16 +52,43 @@ function getStoredToken(account?: ApolloInternetAccountModel) { } } +function isGuestToken(token: string) { + try { + const { username, email } = getDecodedToken(token) + return ( + username?.toLowerCase() === 'guest' || + email?.toLowerCase() === 'guest_user' + ) + } catch { + return false + } +} + function getSignedInApolloAccount(rootModel: ApolloRootModel) { - return getApolloInternetAccounts(rootModel).find((account) => - Boolean(getStoredToken(account)), + const signedInAccounts = getApolloInternetAccounts(rootModel).filter( + (account) => Boolean(getStoredToken(account)), ) + const nonGuestSignedInAccount = signedInAccounts.find((account) => { + const token = getStoredToken(account) + return token ? !isGuestToken(token) : false + }) + return nonGuestSignedInAccount ?? signedInAccounts[0] } function getDefaultApolloAccount(rootModel: ApolloRootModel) { return getApolloInternetAccounts(rootModel).find(Boolean) } +function promptApolloLogin(account: ApolloInternetAccountModel) { + const maybePromptableAccount = account as ApolloInternetAccountModel & { + loginWithPrompt?: () => Promise + } + if (typeof maybePromptableAccount.loginWithPrompt === 'function') { + return maybePromptableAccount.loginWithPrompt() + } + return account.getToken() +} + function isApolloSignedIn(rootModel: ApolloRootModel) { return Boolean(getStoredToken(getSignedInApolloAccount(rootModel))) } @@ -103,10 +112,6 @@ function ApolloUserMenuLabel({ rootModel }: { rootModel: ApolloRootModel }) { return getCurrentApolloUserLabel(rootModel) } -function ApolloAuthMenuLabel({ rootModel }: { rootModel: ApolloRootModel }) { - return isApolloSignedIn(rootModel) ? 'Log out' : 'Log in' -} - function notifyCurrentApolloUser( rootModel: ApolloRootModel, session: ApolloSessionModel, @@ -200,26 +205,11 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { }) rootModel.appendToMenu('Apollo', { - label: React.createElement(ApolloAuthMenuLabel, { - rootModel: rootModel as unknown as ApolloRootModel, - }), - icon: LogoutIcon, + label: 'Log in', + icon: AccountCircleIcon, onClick: (session: ApolloSessionModel) => { const apolloRootModel = rootModel as unknown as ApolloRootModel const sessionModel = session as unknown as AbstractSessionModel - if (isApolloSignedIn(apolloRootModel)) { - sessionModel.queueDialog((doneCallback) => [ - LogOut, - { - rootModel: apolloRootModel, - handleClose: () => { - doneCallback() - }, - }, - ]) - return - } - const defaultAccount = getDefaultApolloAccount(apolloRootModel) if (!defaultAccount) { sessionModel.notify( @@ -229,8 +219,7 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { return } - void defaultAccount - .getToken() + void promptApolloLogin(defaultAccount) .then((token) => { try { const { username, email, role } = getDecodedToken(token) @@ -240,8 +229,14 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { `Apollo login successful: ${identity}${roleText}`, 'success', ) + // Rebuild session/view track selector state after auth changes. + // This avoids stale "Available tracks" panels after guest <-> user. + globalThis.location.reload() + return } catch { sessionModel.notify('Apollo login successful', 'success') + globalThis.location.reload() + return } }) .catch((error: unknown) => { @@ -257,5 +252,28 @@ export function addTopLevelMenus(rootModel: AbstractMenuManager) { }) }, }) + + rootModel.appendToMenu('Apollo', { + label: 'Log out', + icon: LogoutIcon, + onClick: (session: ApolloSessionModel) => { + const apolloRootModel = rootModel as unknown as ApolloRootModel + const sessionModel = session as unknown as AbstractSessionModel + if (!isApolloSignedIn(apolloRootModel)) { + sessionModel.notify('Apollo is already signed out', 'info') + return + } + + sessionModel.queueDialog((doneCallback) => [ + LogOut, + { + rootModel: apolloRootModel, + handleClose: () => { + doneCallback() + }, + }, + ]) + }, + }) } } diff --git a/packages/jbrowse-plugin-apollo/src/session/session.ts b/packages/jbrowse-plugin-apollo/src/session/session.ts index d1e444a57..d80451b1a 100644 --- a/packages/jbrowse-plugin-apollo/src/session/session.ts +++ b/packages/jbrowse-plugin-apollo/src/session/session.ts @@ -8,6 +8,7 @@ import { ImportJBrowseConfigChange, type JBrowseConfig, type UserLocation, + getDecodedToken, filterJBrowseConfig, } from '@apollo-annotation/shared' import type PluginManager from '@jbrowse/core/PluginManager' @@ -77,6 +78,18 @@ export interface HoveredFeature { type Assembly = Instance>['assemblies'][0] +interface AssemblyPermissionResponse { + assemblyId?: string + assembly?: string + canViewAnnotations: boolean + canEditAnnotations: boolean +} + +interface AssemblyResponse { + _id: string + name: string +} + export function extendSession( pluginManager: PluginManager, sessionModel: ReturnType, @@ -116,6 +129,95 @@ export function extendSession( } } + const isGuestToken = (token: string) => { + try { + const { username, email } = getDecodedToken(token) + return ( + username?.toLowerCase() === 'guest' || + email?.toLowerCase() === 'guest_user' + ) + } catch { + return false + } + } + + const removeApolloTracksFromSession = (session: AbstractSessionModel) => { + const sessionWithDelete = session as AbstractSessionModel & { + deleteTrackConf?: (conf: BaseTrackConfig) => void + } + const jbrowseWithDelete = getRoot( + session as unknown as ApolloSessionModel, + ).jbrowse as { + tracks?: BaseTrackConfig[] + deleteTrackConf?: (conf: BaseTrackConfig) => void + } + + const apolloTracks = new Map() + const collectApolloTracks = (trackList?: BaseTrackConfig[]) => { + for (const track of trackList ?? []) { + const trackType = (track as unknown as { type?: string }).type + const trackId = readConfObject(track, 'trackId') as string | undefined + if ( + trackType === 'ApolloTrack' || + trackId?.startsWith('apollo_track_') + ) { + apolloTracks.set(trackId ?? String(apolloTracks.size), track) + } + } + } + + collectApolloTracks([...session.tracks]) + collectApolloTracks(jbrowseWithDelete.tracks) + + for (const track of apolloTracks.values()) { + sessionWithDelete.deleteTrackConf?.(track) + jbrowseWithDelete.deleteTrackConf?.(track) + } + } + + const getViewableAssemblyNamesForAccount = async ( + internetAccount: ApolloInternetAccountModel, + signal: AbortSignal, + ) => { + const { baseURL } = internetAccount + const assembliesUri = new URL('assemblies', baseURL).href + const permissionsUri = new URL('assemblyPermissions/mine', baseURL).href + const apolloFetch = internetAccount.getFetcher({ + locationType: 'UriLocation', + uri: permissionsUri, + }) + + const [assembliesResponse, permissionsResponse] = await Promise.all([ + apolloFetch(assembliesUri, { method: 'GET', signal }), + apolloFetch(permissionsUri, { method: 'GET', signal }), + ]) + + if (!assembliesResponse.ok || !permissionsResponse.ok) { + return new Set() + } + + const assemblies = (await assembliesResponse.json()) as AssemblyResponse[] + const permissions = + (await permissionsResponse.json()) as AssemblyPermissionResponse[] + const assemblyIdToName = new Map( + assemblies.map((assembly) => [assembly._id, assembly.name]), + ) + + return new Set( + permissions + .filter( + (permission) => + permission.canViewAnnotations || permission.canEditAnnotations, + ) + .map((permission) => + assemblyIdToName.get( + permission.assemblyId ?? permission.assembly ?? '', + ), + ) + .filter((name): name is string => Boolean(name)), + ) + } + const AnnotationFeatureExtended = pluginManager.evaluateExtensionPoint( 'Apollo-extendAnnotationFeature', AnnotationFeatureModel, @@ -382,6 +484,31 @@ export function extendSession( if (assemblies.length === 0) { return } + + const sessionModel = self as unknown as AbstractSessionModel + const signedInApolloAccount = internetAccounts + .filter((ia): ia is ApolloInternetAccountModel => + isApolloInternetAccount(ia), + ) + .filter((ia) => Boolean(safeRetrieveToken(ia))) + .sort((a, b) => { + const aToken = safeRetrieveToken(a) + const bToken = safeRetrieveToken(b) + const aGuest = aToken ? isGuestToken(aToken) : true + const bGuest = bToken ? isGuestToken(bToken) : true + return Number(aGuest) - Number(bGuest) + })[0] + const signedInToken = signedInApolloAccount + ? safeRetrieveToken(signedInApolloAccount) + : undefined + + if (!signedInToken || isGuestToken(signedInToken)) { + self.apolloSetSelectedFeature(undefined) + removeApolloTracksFromSession(sessionModel) + reaction.dispose() + return + } + const { pluginConfiguration } = self.apolloDataStore const configuredOntologies = pluginConfiguration.ontologies as AnyConfigurationModel[] @@ -400,11 +527,23 @@ export function extendSession( }, }) } + + const viewableAssemblyNames = + await getViewableAssemblyNamesForAccount( + signedInApolloAccount, + self.abortController.signal, + ) + + self.apolloSetSelectedFeature(undefined) + // @ts-expect-error not sure why snapshot type is wrong for snapshot + applySnapshot(self, self.previousSnapshot) + removeApolloTracksFromSession(sessionModel) for (const a of nonApolloAssemblies) { + if (!viewableAssemblyNames.has(a.name)) { + continue + } self.addApolloLocalTrackConfig(a) } - // @ts-expect-error not sure why snapshot type is wrong for snapshot - applySnapshot(self, self.previousSnapshot) reaction.dispose() return } From 15732f306492ff1d6a2791d31fb805e7c5781e1f Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:30:13 -0400 Subject: [PATCH 13/31] Start repo-wide lint remediation wave 1 - add lint remediation runbook with baseline/process/findings\n- apply first wave of manual lint fixes in plugin auth/session files\n- fix hooks ordering in logout dialog and stabilize effect deps\n- remove several no-useless-undefined/array-type/conditional issues\n\nAssisted-by: GitHub Copilot (GPT-5.3-Codex) --- LINT_REMEDIATION_2026-06-11.md | 106 ++++++++++++++++++ .../components/AuthTypeSelector.tsx | 26 +++-- .../src/ApolloInternetAccount/model.ts | 16 ++- .../src/ApolloInternetAccount/tokenUtils.ts | 2 +- .../src/ChangeManager.ts | 10 +- .../src/components/LogOut.tsx | 78 +++++++------ .../src/components/ManageUsers.tsx | 76 +++++++------ .../src/menus/topLevelMenu.ts | 22 ++-- 8 files changed, 231 insertions(+), 105 deletions(-) create mode 100644 LINT_REMEDIATION_2026-06-11.md diff --git a/LINT_REMEDIATION_2026-06-11.md b/LINT_REMEDIATION_2026-06-11.md new file mode 100644 index 000000000..e08a4a8fb --- /dev/null +++ b/LINT_REMEDIATION_2026-06-11.md @@ -0,0 +1,106 @@ +# Apollo3 Lint Remediation Log (2026-06-11) + +## Goal + +Bring the repository toward passing strict lint in CI +(`yarn eslint --max-warnings 0`) while preserving runtime behavior. + +## Baseline findings + +- CI run: + `https://github.com/USDA-REE-ARS/nal-i5k-apollo3/actions/runs/27356941686` +- Reported summary from run log: + - `398 problems (353 errors, 45 warnings)` + - Affected packages include: + - `packages/jbrowse-plugin-apollo` + - `packages/apollo-collaboration-server` + +### Highest-error files observed in captured log slice + +- `packages/apollo-collaboration-server/src/changes/changes.service.spec.ts` +- `packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx` +- `packages/jbrowse-plugin-apollo/src/session/session.ts` +- `packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts` + +### Most common rules in captured log slice + +- `@typescript-eslint/no-unsafe-member-access` +- `@typescript-eslint/no-unsafe-call` +- `@typescript-eslint/no-unnecessary-condition` +- `unicorn/no-useless-undefined` +- `@typescript-eslint/no-unsafe-assignment` + +## Reproduction notes + +1. Workflow lint command: + - `yarn eslint --max-warnings 0` +2. Local host reproduction on Node 26 is not representative due ESLint config + loader runtime mismatch. +3. Containerized Node 24 reproduction matched CI behavior but hit memory + pressure when reinstall/linking full workspace dependencies. +4. Targeted file-only ESLint invocation in container currently crashes early + with `TypeError: Cannot convert undefined or null to object` while loading + flat config, so validation is being tracked via CI reruns after each fix + wave. + +## Fix strategy + +1. Fix changed plugin files first (security/auth/session work already in + progress). +2. Land cleanup in small, reviewable commits by rule family. +3. Expand to collaboration-server files next, focusing on high-count files. +4. Re-run lint in CI after each fix wave and record delta here. + +## Fix wave log + +### Wave 1 (in progress) + +- Scope: + - `packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts` + - `packages/jbrowse-plugin-apollo/src/ChangeManager.ts` + - `packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts` + - `packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx` + - `packages/jbrowse-plugin-apollo/src/components/LogOut.tsx` + - `packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx` +- Focused rule families: + - `unicorn/no-useless-undefined` + - `@typescript-eslint/array-type` + - `@typescript-eslint/no-unnecessary-condition` + - `@typescript-eslint/consistent-type-definitions` + - `unicorn/prefer-switch` + - `react-hooks/rules-of-hooks` (structural fixes only) + +### Wave 1 status + +- Completed edits in: + - `packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts` + - `packages/jbrowse-plugin-apollo/src/ChangeManager.ts` + - `packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts` + - `packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx` + - `packages/jbrowse-plugin-apollo/src/components/LogOut.tsx` + - `packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx` + - `packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts` +- What was fixed in this wave: + - replaced `type` with `interface` where required + - removed useless `undefined` returns + - simplified unnecessary optional chains + - replaced callback-reference filters with explicit callback lambdas + - replaced if/else ladder with `switch` + - fixed hook ordering issue in logout dialog + - added `useCallback` for effect dependency stability in ManageUsers + +## Upstream contribution path + +Yes, this cleanup is suitable to submit upstream. + +Recommended packaging for upstream developers: + +1. PR 1: plugin-only lint debt reductions in touched files. +2. PR 2: collaboration-server test/type-safety lint fixes. +3. PR 3: any workflow or lint-config adjustments (if needed) with rationale. + +Each PR should include: + +- Before/after lint counts for impacted paths. +- Notes on behavior-preserving refactors. +- Any intentionally deferred rules with justification. diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx index 4c5f3b3f6..8b9f66813 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/components/AuthTypeSelector.tsx @@ -93,19 +93,27 @@ export const AuthTypeSelector = ({ ), ) } - }, [baseURL, preferGuest]) + }, [baseURL, handleClose, preferGuest]) function handleClick( authType: 'google' | 'microsoft' | 'logingov' | 'guest', ) { - if (authType === 'google') { - handleClose('google') - } else if (authType === 'microsoft') { - handleClose('microsoft') - } else if (authType === 'logingov') { - handleClose('logingov') - } else { - handleClose('guest') + switch (authType) { + case 'google': { + handleClose('google') + break + } + case 'microsoft': { + handleClose('microsoft') + break + } + case 'logingov': { + handleClose('logingov') + break + } + default: { + handleClose('guest') + } } } diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts index 13b7b219f..40fcfbc79 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/model.ts @@ -43,7 +43,7 @@ import type { ApolloInternetAccountConfigModel } from './configSchema' import { revokeOtherApolloTokens } from './tokenUtils' type AuthType = 'google' | 'microsoft' | 'logingov' | 'guest' -type LocalAuthSelection = { +interface LocalAuthSelection { type: 'local' identifier: string password: string @@ -53,10 +53,9 @@ type AuthSelection = AuthType | LocalAuthSelection type Role = 'admin' | 'user' | 'readOnly' | 'none' function isGuestIdentity(decodedToken: { username?: string; email?: string }) { - return ( - decodedToken.username?.toLowerCase() === 'guest' || - decodedToken.email?.toLowerCase() === 'guest_user' - ) + const normalizedUsername = String(decodedToken.username).toLowerCase() + const normalizedEmail = String(decodedToken.email).toLowerCase() + return normalizedUsername === 'guest' || normalizedEmail === 'guest_user' } const inWebWorker = typeof sessionStorage === 'undefined' @@ -147,8 +146,8 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { } catch { return } - const apolloAccounts = rootModel.internetAccounts.filter( - isApolloInternetAccount, + const apolloAccounts = rootModel.internetAccounts.filter((account) => + isApolloInternetAccount(account), ) revokeOtherApolloTokens(apolloAccounts, self.tokenKey) }, @@ -341,8 +340,7 @@ const stateModelFactory = (configSchema: ApolloInternetAccountConfigModel) => { return } if (typeof authType === 'string' && authType !== 'guest') { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - self.openAuthWindow(authType, resolve, reject) + void self.openAuthWindow(authType, resolve, reject) return } const url = new URL('auth/login', baseURL) diff --git a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts index 8fb4769e3..f3e008eed 100644 --- a/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts +++ b/packages/jbrowse-plugin-apollo/src/ApolloInternetAccount/tokenUtils.ts @@ -1,4 +1,4 @@ -export type ApolloTokenAccount = { +export interface ApolloTokenAccount { tokenKey: string removeToken(): void } diff --git a/packages/jbrowse-plugin-apollo/src/ChangeManager.ts b/packages/jbrowse-plugin-apollo/src/ChangeManager.ts index a48e8411d..5e10e7a4f 100644 --- a/packages/jbrowse-plugin-apollo/src/ChangeManager.ts +++ b/packages/jbrowse-plugin-apollo/src/ChangeManager.ts @@ -3,9 +3,9 @@ import { isAssemblySpecificChange, } from '@apollo-annotation/common' import { + getDecodedToken, type ValidationResultSet, validationRegistry, - getDecodedToken, } from '@apollo-annotation/shared' import { getSession } from '@jbrowse/core/util' @@ -40,12 +40,12 @@ export class ChangeManager { const session = getSession(this.dataStore) const controller = new AbortController() - const internetAccounts = this.dataStore.internetAccounts as Array<{ + const internetAccounts = this.dataStore.internetAccounts as { type?: string internetAccountId?: string role?: string retrieveToken?: () => string | undefined - }> + }[] const apolloAccounts = internetAccounts.filter( (account) => account.type === 'ApolloInternetAccount', ) @@ -66,8 +66,8 @@ export class ChangeManager { const decoded = getDecodedToken(token) decodedRole = decoded.role isGuest = - decoded.username?.toLowerCase() === 'guest' || - decoded.email?.toLowerCase() === 'guest_user' + decoded.username.toLowerCase() === 'guest' || + decoded.email.toLowerCase() === 'guest_user' } catch { // ignore decode errors here and fall back to account role below } diff --git a/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx b/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx index c7ec72052..141b8608c 100644 --- a/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/LogOut.tsx @@ -1,3 +1,6 @@ +import { readConfObject } from '@jbrowse/core/configuration' +import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' +import { isAlive } from '@jbrowse/mobx-state-tree' import { Button, DialogActions, @@ -7,9 +10,6 @@ import { Select, type SelectChangeEvent, } from '@mui/material' -import type { BaseTrackConfig } from '@jbrowse/core/pluggableElementTypes' -import { readConfObject } from '@jbrowse/core/configuration' -import { isAlive } from '@jbrowse/mobx-state-tree' import React, { useState } from 'react' import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' @@ -26,9 +26,7 @@ function isPrivilegedApolloTrack(track: BaseTrackConfig) { const trackType = (track as unknown as { type?: string }).type const trackId = readConfObject(track, 'trackId') as string | undefined const displays = - (readConfObject(track, 'displays') as - | Array<{ type?: string }> - | undefined) ?? [] + (readConfObject(track, 'displays') as { type?: string }[] | undefined) ?? [] const hasApolloDisplay = displays.some((display) => ['LinearApolloDisplay', 'LinearApolloSixFrameDisplay'].includes( display.type ?? '', @@ -73,7 +71,7 @@ function removePrivilegedTracksForGuest(rootModel: ApolloRootModel) { collect(session.tracks) collect(jbrowse.tracks) - session.apolloSetSelectedFeature?.(undefined) + session.apolloSetSelectedFeature?.() for (const track of byTrackId.values()) { session.deleteTrackConf?.(track) jbrowse.deleteTrackConf?.(track) @@ -82,8 +80,6 @@ function removePrivilegedTracksForGuest(rootModel: ApolloRootModel) { export function LogOut({ handleClose, rootModel }: DeleteAssemblyProps) { const { internetAccounts } = rootModel - const [errorMessage, setErrorMessage] = useState('') - const [isSubmitting, setIsSubmitting] = useState(false) const apolloInternetAccounts = internetAccounts.filter((account) => { try { if (!isAlive(account)) { @@ -95,14 +91,17 @@ export function LogOut({ handleClose, rootModel }: DeleteAssemblyProps) { } }) as ApolloInternetAccountModel[] + const initialSelectedAccount = apolloInternetAccounts[0] + const [errorMessage, setErrorMessage] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + const [selectedInternetAccount, setSelectedInternetAccount] = useState( + initialSelectedAccount, + ) + if (apolloInternetAccounts.length === 0) { return null } - const [selectedInternetAccount, setSelectedInternetAccount] = useState( - apolloInternetAccounts[0], - ) - function handleChangeInternetAccount(e: SelectChangeEvent) { const newlySelectedInternetAccount = apolloInternetAccounts.find( (ia) => ia.internetAccountId === e.target.value, @@ -115,33 +114,40 @@ export function LogOut({ handleClose, rootModel }: DeleteAssemblyProps) { setSelectedInternetAccount(newlySelectedInternetAccount) } - async function onSubmit(event: React.FormEvent) { + function onSubmit(event: React.FormEvent) { event.preventDefault() + if (!selectedInternetAccount) { + return + } setErrorMessage('') setIsSubmitting(true) - try { - selectedInternetAccount.removeToken() - const guestLoginUrl = new URL( - 'auth/login', - selectedInternetAccount.baseURL, - ) - guestLoginUrl.search = new URLSearchParams({ type: 'guest' }).toString() - const response = await fetch(guestLoginUrl.toString(), { method: 'GET' }) - if (!response.ok) { - throw new Error(`Guest login failed (${response.status})`) - } - const { token } = (await response.json()) as { token?: string } - if (!token) { - throw new Error('Guest login did not return a token') + void (async () => { + try { + selectedInternetAccount.removeToken() + const guestLoginUrl = new URL( + 'auth/login', + selectedInternetAccount.baseURL, + ) + guestLoginUrl.search = new URLSearchParams({ type: 'guest' }).toString() + const response = await fetch(guestLoginUrl.toString(), { + method: 'GET', + }) + if (!response.ok) { + throw new Error(`Guest login failed (${response.status})`) + } + const { token } = (await response.json()) as { token?: string } + if (!token) { + throw new Error('Guest login did not return a token') + } + selectedInternetAccount.applyLoggedInToken(token) + removePrivilegedTracksForGuest(rootModel) + handleClose() + } catch { + setErrorMessage('Could not log out from this account. Please retry.') + } finally { + setIsSubmitting(false) } - selectedInternetAccount.applyLoggedInToken(token) - removePrivilegedTracksForGuest(rootModel) - handleClose() - } catch { - setErrorMessage('Could not log out from this account. Please retry.') - } finally { - setIsSubmitting(false) - } + })() } return ( diff --git a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx index 90a82a56f..80597ab61 100644 --- a/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/ManageUsers.tsx @@ -37,7 +37,7 @@ import { type GridRowParams, GridToolbar, } from '@mui/x-data-grid' -import React, { useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' import type { ChangeManager } from '../ChangeManager' @@ -144,11 +144,13 @@ export function ManageUsers({ }: ManageUsersProps) { const { internetAccounts } = getRoot(session) const apolloInternetAccounts = internetAccounts - .filter((ia) => isApolloInternetAccount(ia)) + .filter((ia): ia is ApolloInternetAccountModel => + isApolloInternetAccount(ia), + ) .filter( (ia: ApolloInternetAccountModel & { role?: string }) => ia.role?.includes('admin') ?? false, - ) as ApolloInternetAccountModel[] + ) if (apolloInternetAccounts.length === 0) { throw new Error('No Apollo internet account found') } @@ -196,34 +198,37 @@ export function ManageUsers({ const [localUserErrorMessage, setLocalUserErrorMessage] = useState('') const [isCreatingLocalUser, setIsCreatingLocalUser] = useState(false) - async function fetchEffectiveAssemblyPermissionsForUser(userId: string) { - const { baseURL } = selectedInternetAccount - const uri = new URL( - `assemblyPermissions/effective/byUser/${userId}`, - baseURL, - ).href - const apolloFetch = selectedInternetAccount.getFetcher({ - locationType: 'UriLocation', - uri, - }) - const response = await apolloFetch(uri, { method: 'GET' }) - if (!response.ok) { - const newErrorMessage = await createFetchErrorMessage( - response, - 'Error when getting effective assembly permissions from db', - ) - throw new Error(newErrorMessage) - } - const permissionData = - (await response.json()) as EffectiveAssemblyPermissionResponse[] - const byAssemblyId: Partial< - Record - > = {} - for (const permission of permissionData) { - byAssemblyId[permission.assemblyId] = permission - } - setEffectiveAssemblyPermissionsByAssemblyId(byAssemblyId) - } + const fetchEffectiveAssemblyPermissionsForUser = useCallback( + async (userId: string) => { + const { baseURL } = selectedInternetAccount + const uri = new URL( + `assemblyPermissions/effective/byUser/${userId}`, + baseURL, + ).href + const apolloFetch = selectedInternetAccount.getFetcher({ + locationType: 'UriLocation', + uri, + }) + const response = await apolloFetch(uri, { method: 'GET' }) + if (!response.ok) { + const newErrorMessage = await createFetchErrorMessage( + response, + 'Error when getting effective assembly permissions from db', + ) + throw new Error(newErrorMessage) + } + const permissionData = + (await response.json()) as EffectiveAssemblyPermissionResponse[] + const byAssemblyId: Partial< + Record + > = {} + for (const permission of permissionData) { + byAssemblyId[permission.assemblyId] = permission + } + setEffectiveAssemblyPermissionsByAssemblyId(byAssemblyId) + }, + [selectedInternetAccount], + ) useEffect(() => { async function getUsers() { @@ -327,7 +332,11 @@ export function ManageUsers({ getAssemblyPermissionsForUser().catch((error) => { setErrorMessage(String(error)) }) - }, [selectedInternetAccount, selectedUserId]) + }, [ + fetchEffectiveAssemblyPermissionsForUser, + selectedInternetAccount, + selectedUserId, + ]) useEffect(() => { async function getEffectiveAssemblyPermissionsForUser() { @@ -1139,8 +1148,7 @@ export function ManageUsers({ genusSpecies: scientificName || 'Unknown', canViewAnnotations: permission?.canViewAnnotations ?? false, canEditAnnotations: permission?.canEditAnnotations ?? false, - source: (permission?.source ?? - 'none') as EffectiveAssemblyPermissionRow['source'], + source: permission?.source ?? 'none', } }) .sort( diff --git a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts index 2e9b9500e..7ebd953a6 100644 --- a/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts +++ b/packages/jbrowse-plugin-apollo/src/menus/topLevelMenu.ts @@ -32,33 +32,33 @@ function getApolloInternetAccounts(rootModel: ApolloRootModel) { function getStoredToken(account?: ApolloInternetAccountModel) { if (!account) { - return undefined + return } try { if (!isAlive(account)) { - return undefined + return } const accountWithTokenKey = account as ApolloInternetAccountModel & { tokenKey?: string } if (!accountWithTokenKey.tokenKey) { - return undefined + return } - return globalThis.sessionStorage - .getItem(accountWithTokenKey.tokenKey) - ?.trim() + const token = globalThis.sessionStorage.getItem( + accountWithTokenKey.tokenKey, + ) + return token ? token.trim() : undefined } catch { - return undefined + return } } function isGuestToken(token: string) { try { const { username, email } = getDecodedToken(token) - return ( - username?.toLowerCase() === 'guest' || - email?.toLowerCase() === 'guest_user' - ) + const normalizedUsername = String(username).toLowerCase() + const normalizedEmail = String(email).toLowerCase() + return normalizedUsername === 'guest' || normalizedEmail === 'guest_user' } catch { return false } From caf894917c3f74f5099feaca095be3c51d78c6ed Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:34:24 -0400 Subject: [PATCH 14/31] lint(collaboration-server): wave-2 spec and auth strategy cleanup Tighten typings and remove unsafe patterns in top collaboration-server lint offenders while preserving behavior. Assisted-by: GitHub Copilot (GPT-5.3-Codex) --- LINT_REMEDIATION_2026-06-11.md | 31 +++++++++++++++++++ .../authentication.service.spec.ts | 27 ++++++++++------ .../authentication/authentication.service.ts | 10 ++++-- .../src/changes/changes.service.spec.ts | 31 ++++++++++--------- .../utils/strategies/login-gov.strategy.ts | 18 ++++++++--- 5 files changed, 86 insertions(+), 31 deletions(-) diff --git a/LINT_REMEDIATION_2026-06-11.md b/LINT_REMEDIATION_2026-06-11.md index e08a4a8fb..8b65f3cd7 100644 --- a/LINT_REMEDIATION_2026-06-11.md +++ b/LINT_REMEDIATION_2026-06-11.md @@ -89,6 +89,37 @@ Bring the repository toward passing strict lint in CI - fixed hook ordering issue in logout dialog - added `useCallback` for effect dependency stability in ManageUsers +### Wave 2 (in progress) + +- Scope: + - `packages/apollo-collaboration-server/src/changes/changes.service.spec.ts` + - `packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts` + - `packages/apollo-collaboration-server/src/authentication/authentication.service.ts` + - `packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts` +- Focused rule families: + - `@typescript-eslint/no-unsafe-assignment` + - `@typescript-eslint/no-unsafe-call` + - `@typescript-eslint/no-unsafe-member-access` + - `@typescript-eslint/no-explicit-any` + - `unicorn/no-await-expression-member` + - `unicorn/consistent-function-scoping` + +### Wave 2 status + +- Completed edits in: + - `packages/apollo-collaboration-server/src/changes/changes.service.spec.ts` + - `packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts` + - `packages/apollo-collaboration-server/src/authentication/authentication.service.ts` + - `packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts` +- What was fixed in this wave: + - introduced typed jest imports/mocks and helper exec factory for specs + - removed `any`-based constructor wiring in auth service spec + - moved auth spec factory to module scope + - rewrote login.gov client-id file read flow to avoid await-expression-member + lint violation + - replaced login.gov strategy non-null assertions and `any` cast with explicit + guards and typed oauth2 agent handling + ## Upstream contribution path Yes, this cleanup is suitable to submit upstream. diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts b/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts index ec6c7e79b..5278f8a1d 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.service.spec.ts @@ -1,18 +1,27 @@ -import { AuthenticationService } from './authentication.service.js' -import { Role } from '../utils/role/role.enum.js' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -describe('AuthenticationService', () => { - function makeService(config: Record) { - const configService = { - get: (key: string) => config[key], - } +import { describe, expect, it } from '@jest/globals' - return new AuthenticationService({} as any, {} as any, configService as any) - } +import { AuthenticationService } from './authentication.service.js' +import { Role } from '../utils/role/role.enum.js' +type AuthServiceCtorParams = ConstructorParameters + +function makeService(config: Record) { + const configService = { + get: (key: string) => config[key], + } as AuthServiceCtorParams[2] + + return new AuthenticationService( + {} as AuthServiceCtorParams[0], + {} as AuthServiceCtorParams[1], + configService, + ) +} + +describe('AuthenticationService', () => { it('returns login types from direct client ID values', async () => { const service = makeService({ DEFAULT_NEW_USER_ROLE: Role.None, diff --git a/packages/apollo-collaboration-server/src/authentication/authentication.service.ts b/packages/apollo-collaboration-server/src/authentication/authentication.service.ts index 5deeef2e9..e87c732f9 100644 --- a/packages/apollo-collaboration-server/src/authentication/authentication.service.ts +++ b/packages/apollo-collaboration-server/src/authentication/authentication.service.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-unnecessary-condition */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import fs from 'node:fs/promises' import type { JWTPayload } from '@apollo-annotation/shared' @@ -106,8 +105,13 @@ export class AuthenticationService { const clientIDFile = this.configService.get('LOGINGOV_CLIENT_ID_FILE', { infer: true, }) - loginGovClientID = - clientIDFile && (await fs.readFile(clientIDFile, 'utf8')).trim() + if (clientIDFile) { + const loginGovClientIdFileValue = await fs.readFile( + clientIDFile, + 'utf8', + ) + loginGovClientID = loginGovClientIdFileValue.trim() + } } const allowLocalUserLogin = this.configService.get( 'ALLOW_LOCAL_USER_LOGIN', diff --git a/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts b/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts index 7bbf25df7..f88400625 100644 --- a/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts +++ b/packages/apollo-collaboration-server/src/changes/changes.service.spec.ts @@ -1,35 +1,35 @@ -import { UnprocessableEntityException } from '@nestjs/common' import { DeleteFeatureChange } from '@apollo-annotation/shared' +import { beforeEach, describe, expect, it, jest } from '@jest/globals' +import { UnprocessableEntityException } from '@nestjs/common' import { ChangesService } from './changes.service.js' -import { jest } from '@jest/globals' + +function makeExec(value: T) { + return { exec: jest.fn<() => Promise>().mockResolvedValue(value) } +} describe('ChangesService', () => { let service: ChangesService const assemblyPermissionsService = { - canEdit: jest.fn(), - getViewableAssemblyIds: jest.fn(), + canEdit: jest.fn<(userId: string, assembly: string) => Promise>(), + getViewableAssemblyIds: jest.fn<(userId: string) => Promise>(), } + const findExec = makeExec([]) + const findSort = { sort: jest.fn(() => findExec) } const changeModel = { - find: jest.fn(() => ({ - sort: jest.fn(() => ({ - exec: jest.fn().mockResolvedValue([]), - })), - })), - countDocuments: jest.fn(() => ({ exec: jest.fn().mockResolvedValue(0) })), + find: jest.fn(() => findSort), + countDocuments: jest.fn(() => makeExec(0)), } const countersService = { - getNextSequenceValue: jest.fn(), + getNextSequenceValue: jest.fn<(counterName: string) => Promise>(), } - const emptyExec = { exec: jest.fn().mockResolvedValue([]) } + const emptyExec = makeExec([]) const assemblyModel = { deleteMany: jest.fn(() => emptyExec) } const refSeqModel = { deleteMany: jest.fn(() => emptyExec) } const refSeqChunkModel = { deleteMany: jest.fn(() => emptyExec) } const featureModel = { db: { - transaction: jest.fn(async (cb: (session: unknown) => Promise) => - cb({}), - ), + transaction: jest.fn(async () => Promise.resolve()), }, deleteMany: jest.fn(() => emptyExec), } @@ -48,6 +48,7 @@ describe('ChangesService', () => { {} as never, {} as never, ) + countersService.getNextSequenceValue.mockResolvedValue(1) }) it('should be defined', () => { diff --git a/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts b/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts index 805ebd983..74d95abf5 100644 --- a/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts +++ b/packages/apollo-collaboration-server/src/utils/strategies/login-gov.strategy.ts @@ -33,6 +33,10 @@ interface LoginGovProfile { _json?: { email?: string; sub?: string } } +interface OAuth2ClientWithAgent { + setAgent(agent: HttpsProxyAgent): void +} + @Injectable() export class LoginGovStrategy extends PassportStrategy(Strategy, 'logingov') { private readonly logger = new Logger(LoginGovStrategy.name) @@ -54,7 +58,7 @@ export class LoginGovStrategy extends PassportStrategy(Strategy, 'logingov') { } let clientSecret = 'none' - let callbackURL + let callbackURL: string | undefined const issuerBaseURL = configService.get('LOGINGOV_ISSUER_BASE_URL', { infer: true }) ?? 'https://secure.login.gov' @@ -79,7 +83,12 @@ export class LoginGovStrategy extends PassportStrategy(Strategy, 'logingov') { const clientSecretFile = configService.get( 'LOGINGOV_CLIENT_SECRET_FILE', { infer: true }, - )! + ) + if (!clientSecretFile) { + throw new Error( + 'LOGINGOV_CLIENT_SECRET or LOGINGOV_CLIENT_SECRET_FILE must be configured when login.gov auth is enabled', + ) + } clientSecret = fs.readFileSync(clientSecretFile, 'utf8').trim() } const urlString = configService.get('URL', { infer: true }) @@ -105,7 +114,8 @@ export class LoginGovStrategy extends PassportStrategy(Strategy, 'logingov') { const proxy = configService.get('OAUTH_HTTP_PROXY', { infer: true }) if (proxy) { const agent = new HttpsProxyAgent(proxy) - const oauth2 = (this as any)._oauth2 + const oauth2 = (this as unknown as { _oauth2?: OAuth2ClientWithAgent }) + ._oauth2 if (oauth2) { oauth2.setAgent(agent) this.logger.debug(`LoginGovStrategy configured to use proxy: ${proxy}`) @@ -113,7 +123,7 @@ export class LoginGovStrategy extends PassportStrategy(Strategy, 'logingov') { } } - async validate(issuer: string, profile: LoginGovProfile) { + async validate(_issuer: string, profile: LoginGovProfile) { return this.authService.loginGovLogin(profile) } } From 05538d4d4c4682e4fb8ea3a4eb0af6f61fd2db5c Mon Sep 17 00:00:00 2001 From: Christopher Childers <126100971+ChrisChilders-USDA@users.noreply.github.com> Date: Thu, 11 Jun 2026 11:38:11 -0400 Subject: [PATCH 15/31] lint(plugin): wave-3 typing cleanup in workspace/session Tighten DataGrid and account-iteration typing in remaining plugin hotspot files while preserving behavior. Assisted-by: GitHub Copilot (GPT-5.3-Codex) --- LINT_REMEDIATION_2026-06-11.md | 23 +++++++++++++++ .../src/components/MyAssemblyPermissions.tsx | 22 ++++++++------ .../src/session/session.ts | 29 ++++++++++--------- 3 files changed, 52 insertions(+), 22 deletions(-) diff --git a/LINT_REMEDIATION_2026-06-11.md b/LINT_REMEDIATION_2026-06-11.md index 8b65f3cd7..7da7f66f4 100644 --- a/LINT_REMEDIATION_2026-06-11.md +++ b/LINT_REMEDIATION_2026-06-11.md @@ -120,6 +120,29 @@ Bring the repository toward passing strict lint in CI - replaced login.gov strategy non-null assertions and `any` cast with explicit guards and typed oauth2 agent handling +### Wave 3 (in progress) + +- Scope: + - `packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx` + - `packages/jbrowse-plugin-apollo/src/session/session.ts` +- Focused rule families: + - `@typescript-eslint/no-unnecessary-type-assertion` + - `@typescript-eslint/no-unsafe-assignment` + - `@typescript-eslint/no-unsafe-call` + - callback typing and explicit render param typing in UI code + +### Wave 3 status + +- Completed edits in: + - `packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx` + - `packages/jbrowse-plugin-apollo/src/session/session.ts` +- What was fixed in this wave: + - removed broad top-level eslint suppressions in My workspace dialog file + - added explicit DataGrid render-cell typing and typed switch change handler + - removed unnecessary string casts when loading assemblies from row data + - introduced typed Apollo account filtering helper in session model + - replaced repeated array-cast loops with type-guarded account iteration + ## Upstream contribution path Yes, this cleanup is suitable to submit upstream. diff --git a/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx index aa92ff078..73108e211 100644 --- a/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx +++ b/packages/jbrowse-plugin-apollo/src/components/MyAssemblyPermissions.tsx @@ -1,12 +1,11 @@ -/* eslint-disable @typescript-eslint/no-misused-promises */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { getDecodedToken } from '@apollo-annotation/shared' -import { applySnapshot, getRoot, isAlive } from '@jbrowse/mobx-state-tree' +import { applySnapshot, isAlive } from '@jbrowse/mobx-state-tree' import type { LinearGenomeViewModel } from '@jbrowse/plugin-linear-genome-view' import FilterListIcon from '@mui/icons-material/FilterList' import { Box, Button, + type ChangeEvent, Chip, DialogActions, DialogContent, @@ -15,7 +14,12 @@ import { Switch, Typography, } from '@mui/material' -import { DataGrid, type GridColDef, GridToolbar } from '@mui/x-data-grid' +import { + DataGrid, + type GridColDef, + type GridRenderCellParams, + GridToolbar, +} from '@mui/x-data-grid' import React, { useEffect, useMemo, useState } from 'react' import type { ApolloInternetAccountModel } from '../ApolloInternetAccount/model' @@ -354,14 +358,14 @@ export function MyAssemblyPermissions({ filterable: false, minWidth: 220, maxWidth: 240, - renderCell: ({ row }) => ( + renderCell: ({ row }: GridRenderCellParams) => (