Skip to content

Feat/account recovery#181

Merged
ValwareIRC merged 4 commits into
feat/auth-modernfrom
feat/account-recovery
May 10, 2026
Merged

Feat/account recovery#181
ValwareIRC merged 4 commits into
feat/auth-modernfrom
feat/account-recovery

Conversation

@ValwareIRC
Copy link
Copy Markdown
Contributor

No description provided.

ValwareIRC added 2 commits May 3, 2026 04:35
Implements the client side of the draft/account-recovery WIP spec we
authored at extensions/account-recovery.md, plus opt-in SASL
EXTERNAL for cert-based login.

* IRCClient.recoverRequest / recoverConfirm / setpass send the new
  commands.  SETPASS uses the IRC trailing-parameter form so passwords
  may contain spaces (passphrases).  No base64.
* New typed events RECOVER_NOTE / RECOVER_FAIL / SETPASS_NOTE /
  SETPASS_FAIL projected from the generic NOTE/FAIL stream so
  components don't have to filter by command on every render.
* PasswordRecoveryModal: three-stage flow (account -> code -> new
  password).  Surfaces the spec's account-existence-ambiguity to the
  user instead of silently swallowing typos.
* ChangePasswordModal: one-shot SETPASS for in-session rotation; only
  shown while the connection is authenticated.
* EditServerModal exposes both modals when the server advertises
  draft/account-recovery.
* SASL EXTERNAL added to the SaslMech union and the AUTHENTICATE
  state machine; an EXTERNAL-configured server replies '+' to '+' and
  the TLS cert provides identity.  EXTERNAL is only chosen when the
  user explicitly picks it -- never under "auto", since it requires a
  device-side client cert.
When the server advertises draft/persistence, the user's account
settings tab now gets a tri-state preference:

  - Stay in channels (ON) -- ghost survives disconnect
  - Leave on disconnect (OFF) -- clean exit
  - Use server default (DEFAULT) -- inherit operator setting

Plumbing:

  - IRCClient.persistenceGet / persistenceSet send PERSISTENCE GET
    and PERSISTENCE SET ON|OFF|DEFAULT.
  - handlePersistence parses the server's PERSISTENCE STATUS reply
    and emits a typed PERSISTENCE_STATUS event; the FAIL handler
    projects PERSISTENCE_FAIL alongside the generic FAIL.
  - registerAuthHandlers caches both preference + effective state on
    the Server record, and fires an automatic PERSISTENCE GET 1.5s
    after CAP ACK so the panel has fresh state when the user opens
    settings (the spec gates the command on IsLoggedIn, hence the
    small post-SASL delay).
  - PersistenceSettingsPanel renders a radio-card list with helper
    text and an "Currently ON/OFF" badge so the user can reconcile
    their preference with what the server is actually doing (e.g.
    preference DEFAULT but the operator default flipped).
  - The panel is mounted in UserSettings -> Account, gated on the
    server's CAP set, so non-supporting networks see nothing change.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 3, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 47bc02ad-3363-4b13-92b8-18f4dcbf39ad

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/account-recovery

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 3, 2026

Pages Preview
Preview URL: https://feat-account-recovery.obsidianirc.pages.dev

Automated deployment preview for the PR in the Cloudflare Pages.

@ValwareIRC ValwareIRC changed the base branch from main to feat/auth-modern May 4, 2026 18:06
Conflicts resolved:
  - src/types/index.ts: kept both persistencePreference/Effective and cmdsAvailable
  - src/lib/irc/IRCClient.ts: kept both RECOVER/SETPASS/PERSISTENCE event types and CMDSLIST
  - src/lib/irc/handlers/index.ts: kept both PERSISTENCE and CMDSLIST dispatch
  - src/store/handlers/auth.ts: kept EXTERNAL + IRCV3BEARER mech branches; persistence + cmdslist subscribers coexist
  - src/components/ui/EditServerModal.tsx: dropped the pre-tab UI's old toggle state and reflowed the password recovery / change-password buttons into the new Authentication tab as a draft/account-recovery section.
@ValwareIRC ValwareIRC merged commit 511a41c into feat/auth-modern May 10, 2026
5 checks passed
ValwareIRC added a commit that referenced this pull request May 11, 2026
* feat: SCRAM-SHA-256, TOTP 2FA + biometric (WebAuthn) login

Adds three new SASL paths plus an enrolment UI for second-factor
credentials, all behind a single feature branch.

* `src/lib/sasl/scram.ts` - SCRAM-SHA-256 (RFC 7677) using Web Crypto
  for PBKDF2/HMAC/SHA-256.  Picked automatically when the server
  advertises it; falls back to PLAIN.
* `src/lib/sasl/webauthn.ts` - thin `navigator.credentials` wrapper for
  the DRAFT-WEBAUTHN-BIO mechanism and `2FA ADD webauthn` enrolment.
* `src/store/handlers/auth.ts` - per-server SASL session state machine
  that dispatches AUTHENTICATE messages by mechanism and routes
  `AUTHENTICATE 2FA-REQUIRED` to the step-up modal.
* `2FA` IRC command + `TWOFA` / `TWOFA_NOTE` events for status, listing,
  enrolment, removal, enable/disable replies from the server.
* `TotpStepUpModal` - prompts for the 6-digit code mid-SASL.
* `TwoFactorSettingsModal` - status, credential list, TOTP enrol with
  QR (qrcode dep), WebAuthn biometric enrol via `navigator.credentials.
  create`, removal, and password-free disable via TOTP proof.
* Wired into `EditServerModal` (button visible when the server
  advertises `draft/account-2fa`).

* feat: tic-tac-toe (compatible with KiwiIRC plugin)

Implements the same TAGMSG protocol used by
ItsOnlyBinary/kiwiirc-plugin-tictactoe so KiwiIRC and ObsidianIRC
users can play each other.

* `src/lib/games/tictactoe.ts` -- pure game logic: board, turn
  bookkeeping, win/draw detection, marker assignment.
* `src/lib/games/tictactoeProtocol.ts` -- IRC message-tag escape and
  unescape per IRCv3 message-tags.
* `src/store/handlers/tictactoeActions.ts` -- store-side actions
  (invite/accept/decline/move/terminate) that mutate state and emit
  TAGMSGs over `+kiwiirc.com/ttt`.
* `src/store/handlers/tictactoe.ts` -- inbound TAGMSG dispatch.
  Auto-opens the modal on incoming invites, replays moves into the
  local board, detects out-of-sync turns, handles forfeit / decline.
* `src/components/ui/TicTacToeModal.tsx` -- minimal 3x3 board UI with
  invite accept/decline, in-game forfeit, and game-over close.
* "Play Tic-Tac-Toe" entry in the chat-header overflow menu when a
  private chat is selected.

* feat: surface tic-tac-toe button in the PM header action row

The overflow menu is only rendered in the channel header (and only on
mobile), so the previous "Play Tic-Tac-Toe" entry was unreachable in
private chats.  Add a dedicated 🎮 button next to the existing Media /
Search controls in the PM action cluster instead.

* 2FA: confirm-before-remove, block removing last credential while enabled

The remove button used to fire an immediate `2FA REMOVE`.  That had two
problems:

1. No undo path -- a stray click silently dropped a TOTP credential.
2. With 2FA enforcement on, the server's REMOVE_LAST_CREDENTIAL guard
   would reject the request, but we'd still surface the failure as a
   plain error toast with no recovery hint.

Now the button just stages a confirm prompt inline.  When 2FA is enabled
and the credential being removed is the only one left, the confirm
prompt is replaced with a "can't remove your last credential" notice
that points the user at adding another credential or disabling 2FA
first.  Otherwise the user gets a normal "remove '<name>'?" confirm.

* Request draft/account-2fa CAP so the client merges it into server.capabilities

Without this CAP REQ, the server's CAP LS advertisement of draft/account-2fa
never lands on server.capabilities (the merger only writes from CAP ACK), so
EditServerModal's gate

  server?.capabilities?.some(c => c.startsWith("draft/account-2fa"))

stays false and the 'Manage two-factor authentication' button never renders.

* Move 2FA management into UserSettings → Account; raise 2FA modal z-index above LoadingOverlay

The button in EditServerModal was buried behind the 'Login to an account'
checkbox, so users wouldn't find it. Surface it in the always-reachable
Account category of UserSettings instead, gated on the currently selected
server's draft/account-2fa capability.

LoadingOverlay sits at z-[100001]; the 2FA / TOTP step-up modals were at
z-50 and got hidden behind it whenever the server reconnected. Bump both
modals to z-[100002].

* Don't send spurious AUTHENTICATE + after SCRAM server-final

Per IRCv3 SASL, the server completes the exchange itself after server-final
by emitting 900/903 (or AUTHENTICATE 2FA-REQUIRED for step-up). UnrealIRCd's
saslserv reads our extra 'AUTHENTICATE +' as an empty/abort payload and
fires 904 SASL authentication failed, which dropped the SASL session before
the user could supply their TOTP code.

* feat(auth): SASL IRCV3BEARER + per-server OAuth2/OIDC sign-in

Lets ObsidianIRC connect to obbyircd via OAuth2 instead of (or in
addition to) PLAIN. Each server entry now carries its own provider
config, so admins can point one server at Logto, another at Auth0,
Keycloak, Okta, or any custom OIDC issuer.

Flow

  1. In Edit Server -> "OAuth2 / OIDC sign-in", admin picks a preset
     (Logto, Auth0, Keycloak, Custom), pastes the issuer URL + client
     ID, optionally tweaks scopes/redirect URI, and clicks "Sign in".
  2. We discover the issuer's metadata, build an authorize URL with
     PKCE (S256), and open the IdP in a popup.
  3. The popup redirects to /oauth/callback (a new same-origin
     SPA route) which postMessages the auth code back to the opener
     and closes itself.
  4. The opener exchanges code+verifier at the token endpoint and
     stores the resulting access/id/refresh tokens on the server.
  5. On every connect, if the server has an access token, the SASL
     step picks IRCV3BEARER, sends "[authzid]\0jwt\0<token>" in 400-
     char AUTHENTICATE chunks, and the server validates the JWT
     against its configured oauth-provider/JWKS.

Notes

  * Tokens live in localStorage with the rest of the server config;
    expired tokens just yield a 904 and the user re-authenticates.
  * IRCClient gained an oauthBearerEnabled flag on connect() so CAP
    requests SASL and we don't race CAP END against AUTHENTICATE.
  * saslFrames.ts also exports buildOauthBearerPayload for the
    RFC 7628 mech, even though the wired-up flow uses IRCV3BEARER.

Tests cover OIDC discovery, PKCE verifier/challenge (incl. the
RFC 7636 vector), authorize-URL construction, and both SASL frame
builders + chunker behavior. Full suite passes (722 tests).

* feat(auth): bake OAuth config into the build for lock-mode deployments

When the client is built with VITE_HIDE_SERVER_LIST=true (single-server
deploy where users can't add their own servers), admins can now bake
the OAuth provider settings into the build via VITE_DEFAULT_OAUTH_*
env vars. Users see a "Sign in with <ProviderLabel>" button instead of
an editable issuer/client_id form -- they don't need to know what
Logto, Auth0 or Keycloak even is.

  VITE_DEFAULT_OAUTH_PROVIDER_LABEL  e.g. "Logto"
  VITE_DEFAULT_OAUTH_ISSUER          e.g. https://my-tenant.logto.app/oidc
  VITE_DEFAULT_OAUTH_CLIENT_ID
  VITE_DEFAULT_OAUTH_SCOPES          optional, defaults "openid"
  VITE_DEFAULT_OAUTH_REDIRECT_URI    optional, defaults <origin>/oauth/callback

Both the welcome AddServerModal and EditServerModal pull
getBuiltinOAuthConfig() and pass it as `locked` to OAuthSection,
which in locked mode renders only the Sign-in / Sign-out actions
plus a token status line. The provider fields are no longer editable.

In multi-server builds (VITE_HIDE_SERVER_LIST unset), behavior is
unchanged: the editable per-server OAuth panel still drives.

Tests cover getBuiltinOAuthConfig() across all four
present/missing-field combinations. Dockerfile + BUILD.md updated.

* refactor(EditServerModal): tabbed left-sidebar layout

The Edit Server modal had grown to seven sections stacked in one
column (server info, account login toggles, server password, SASL,
OAuth, operator, register-account dead code). Migrate it to the same
left-sidebar tabbed shell as ChannelSettingsModal:

  General        - network name, host, port, nickname
  Authentication - SASL PLAIN account/password + server password
  OAuth2         - the OAuthSection (locked or editable)
  IRC Operator   - oper credentials + on-connect toggle + Oper Up

Desktop renders sidebar + main content in a single 80vh dialog;
mobile mirrors ChannelSettingsModal's two-step categories ->
content navigation with back chevron, identical safe-area
insets, and an always-visible Cancel/Update footer.

Validation errors now jump back to the General tab (mobile: into
the content view) so the user sees what's missing instead of
silently failing on submit.

Drops the dead "Register for an account" section from this
modal: the registerAccount/email/password state was collected but
never sent on submit -- registration only happens at server-add
time via AddServerModal -> store.connect().

Behavior preserved:

  * SASL PLAIN account/password (was: state initialized empty,
    btoa'd into config on submit). Now also exposes "(stored --
    leave blank to keep)" placeholder when a SASL/oper/server
    password already exists, fixing the prior surprise where the
    field appeared blank but was actually preserved on save.
  * Operator credentials, on-connect toggle, "Forget Credentials"
    + "Oper Up" buttons.
  * OAuth: locked-mode (HIDE_SERVER_LIST + builtin config) still
    surfaces only Sign in / Sign out; editable mode still gets the
    full preset/issuer/clientId/scopes form.

Implementation also picks up modal-behavior parity with
ChannelSettingsModal: createPortal rendering, useModalBehavior for
Esc + click-outside, animated fade-in on mobile.

No tests changed -- this is a UI-shell refactor over the same
state shape; existing 726-test suite continues to pass.

* feat(auth): opaque-token (GitHub) + id_token (Gmail) OAuth paths

Adds the client side of the obbyircd opaque-token feature. Users can
now sign in with any provider obbyircd is configured for, including
GitHub-style OAuth2 (opaque tokens, server hits userinfo endpoint)
and Google-style OIDC (id_token JWT, server validates locally).

Two new fields on ServerOAuthConfig:

  tokenKind:       "jwt" (default) or "opaque"
  serverProvider:  name of the matching oauth-provider {} on the IRC
                   server, sent as IRCV3BEARER authzid (or OAUTHBEARER
                   `provider=` k/v) so obbyircd picks the right
                   userinfo URL

For non-OIDC providers (GitHub) we add explicit endpoint overrides
since there's no /.well-known/openid-configuration to discover:

  authorizeEndpoint, tokenEndpoint

Built-in presets gain Google (jwt, OIDC) and GitHub (opaque, manual
endpoints) so the dropdown auto-fills issuer/scopes/endpoints/
tokenKind/serverProvider in one click.

JWT path now prefers id_token over access_token (matters for Google,
whose access_token is opaque even when an id_token is also issued).

Lock-mode build vars (single-server deploy) gain matching siblings:

  VITE_DEFAULT_OAUTH_TOKEN_KIND
  VITE_DEFAULT_OAUTH_SERVER_PROVIDER
  VITE_DEFAULT_OAUTH_AUTHORIZE_URL
  VITE_DEFAULT_OAUTH_TOKEN_URL

so admins can ship a "Sign in with GitHub" or "Sign in with Google"
build with no per-user configuration.

BUILD.md + Dockerfile updated; SASL frame builder gets a new test
covering the opaque format. 727 tests pass.

For each preset the corresponding obbyircd oauth-provider {} block
is documented inline (Google: jwks-file, GitHub: userinfo-url).

* feat(auth): static /oauth/callback page

The SPA bundle uses base="./" so its asset paths are relative; loading
the React OAuthCallback at /oauth/callback would resolve assets to
/oauth/assets/... and 404. Replace the in-SPA route with a self-contained
public/oauth/callback/index.html that just postMessages the auth code to
its opener and closes -- no SPA bundle dependency, works under any base
config and any deep-link routing setup.

* feat(auth): smart 2FA step-up — IRCV3BEARER step-up + reject same-factor replay

Wires the client to the new server-side step-up policy:

  - When SASL primary is PLAIN/SCRAM and an OAuth bearer is also
    configured, on AUTHENTICATE 2FA-REQUIRED the client now
    automatically replies with AUTHENTICATE IRCV3BEARER + the chunked
    bearer payload, satisfying 2FA in one round trip with no UI prompt.

  - When SASL primary is IRCV3BEARER (the bearer is already spent as
    primary), the auto-path is skipped on 2FA-REQUIRED so the
    TotpStepUpModal pops -- replaying the same bearer would just be
    rejected by the server's same-factor guard.

  - Drops the made-up "AUTHENTICATE 2FA-OAUTH" mech name. Step-up uses
    the real IRCV3BEARER mech with identical wire format; the server
    routes to the step-up handler based on TwoFAStepup state.

  - Two new store actions: twofaToken(serverId, chunk) and
    linkOauthCredential(serverId, provider, bearer, name) which run
    /2FA CHALLENGE oauth + chunked /2FA TOKEN + /2FA ADD oauth.
    twofaAdd's data param became optional (oauth ADD takes no inline
    data -- token was already streamed).

  - TwoFactorSettingsModal grows a "Link an OAuth identity" card that
    runs the popup OAuth flow against the per-server (or
    deployer-baked, in lock-mode builds) provider config and binds the
    resulting (provider, subject) credential to the account via the
    actions above.

* Feat/account recovery (#181)

* feat: draft/account-recovery (RECOVER + SETPASS) and SASL EXTERNAL

Implements the client side of the draft/account-recovery WIP spec we
authored at extensions/account-recovery.md, plus opt-in SASL
EXTERNAL for cert-based login.

* IRCClient.recoverRequest / recoverConfirm / setpass send the new
  commands.  SETPASS uses the IRC trailing-parameter form so passwords
  may contain spaces (passphrases).  No base64.
* New typed events RECOVER_NOTE / RECOVER_FAIL / SETPASS_NOTE /
  SETPASS_FAIL projected from the generic NOTE/FAIL stream so
  components don't have to filter by command on every render.
* PasswordRecoveryModal: three-stage flow (account -> code -> new
  password).  Surfaces the spec's account-existence-ambiguity to the
  user instead of silently swallowing typos.
* ChangePasswordModal: one-shot SETPASS for in-session rotation; only
  shown while the connection is authenticated.
* EditServerModal exposes both modals when the server advertises
  draft/account-recovery.
* SASL EXTERNAL added to the SaslMech union and the AUTHENTICATE
  state machine; an EXTERNAL-configured server replies '+' to '+' and
  the TLS cert provides identity.  EXTERNAL is only chosen when the
  user explicitly picks it -- never under "auto", since it requires a
  device-side client cert.

* feat: draft/persistence settings panel

When the server advertises draft/persistence, the user's account
settings tab now gets a tri-state preference:

  - Stay in channels (ON) -- ghost survives disconnect
  - Leave on disconnect (OFF) -- clean exit
  - Use server default (DEFAULT) -- inherit operator setting

Plumbing:

  - IRCClient.persistenceGet / persistenceSet send PERSISTENCE GET
    and PERSISTENCE SET ON|OFF|DEFAULT.
  - handlePersistence parses the server's PERSISTENCE STATUS reply
    and emits a typed PERSISTENCE_STATUS event; the FAIL handler
    projects PERSISTENCE_FAIL alongside the generic FAIL.
  - registerAuthHandlers caches both preference + effective state on
    the Server record, and fires an automatic PERSISTENCE GET 1.5s
    after CAP ACK so the panel has fresh state when the user opens
    settings (the spec gates the command on IsLoggedIn, hence the
    small post-SASL delay).
  - PersistenceSettingsPanel renders a radio-card list with helper
    text and an "Currently ON/OFF" badge so the user can reconcile
    their preference with what the server is actually doing (e.g.
    preference DEFAULT but the operator default flipped).
  - The panel is mounted in UserSettings -> Account, gated on the
    server's CAP set, so non-supporting networks see nothing change.

* Feat/read marker (#182)

* feat: draft/account-recovery (RECOVER + SETPASS) and SASL EXTERNAL

Implements the client side of the draft/account-recovery WIP spec we
authored at extensions/account-recovery.md, plus opt-in SASL
EXTERNAL for cert-based login.

* IRCClient.recoverRequest / recoverConfirm / setpass send the new
  commands.  SETPASS uses the IRC trailing-parameter form so passwords
  may contain spaces (passphrases).  No base64.
* New typed events RECOVER_NOTE / RECOVER_FAIL / SETPASS_NOTE /
  SETPASS_FAIL projected from the generic NOTE/FAIL stream so
  components don't have to filter by command on every render.
* PasswordRecoveryModal: three-stage flow (account -> code -> new
  password).  Surfaces the spec's account-existence-ambiguity to the
  user instead of silently swallowing typos.
* ChangePasswordModal: one-shot SETPASS for in-session rotation; only
  shown while the connection is authenticated.
* EditServerModal exposes both modals when the server advertises
  draft/account-recovery.
* SASL EXTERNAL added to the SaslMech union and the AUTHENTICATE
  state machine; an EXTERNAL-configured server replies '+' to '+' and
  the TLS cert provides identity.  EXTERNAL is only chosen when the
  user explicitly picks it -- never under "auto", since it requires a
  device-side client cert.

* feat: draft/persistence settings panel

When the server advertises draft/persistence, the user's account
settings tab now gets a tri-state preference:

  - Stay in channels (ON) -- ghost survives disconnect
  - Leave on disconnect (OFF) -- clean exit
  - Use server default (DEFAULT) -- inherit operator setting

Plumbing:

  - IRCClient.persistenceGet / persistenceSet send PERSISTENCE GET
    and PERSISTENCE SET ON|OFF|DEFAULT.
  - handlePersistence parses the server's PERSISTENCE STATUS reply
    and emits a typed PERSISTENCE_STATUS event; the FAIL handler
    projects PERSISTENCE_FAIL alongside the generic FAIL.
  - registerAuthHandlers caches both preference + effective state on
    the Server record, and fires an automatic PERSISTENCE GET 1.5s
    after CAP ACK so the panel has fresh state when the user opens
    settings (the spec gates the command on IsLoggedIn, hence the
    small post-SASL delay).
  - PersistenceSettingsPanel renders a radio-card list with helper
    text and an "Currently ON/OFF" badge so the user can reconcile
    their preference with what the server is actually doing (e.g.
    preference DEFAULT but the operator default flipped).
  - The panel is mounted in UserSettings -> Account, gated on the
    server's CAP set, so non-supporting networks see nothing change.

* feat: draft/read-marker (MARKREAD)

Cross-session read state: when one of your devices reads up to a
particular message, the other devices clear their unread / mention
state for that buffer too.

  - draft/read-marker added to ourCaps so the cap is auto-requested
    when the server advertises it.
  - IRCClient.markreadGet / markreadSet send the GET and SET wire
    forms.  SET uses the "timestamp=YYYY-MM-DDThh:mm:ss.sssZ" format
    the spec requires.
  - handleMarkread parses ":server MARKREAD <target> {timestamp=ts|*}"
    into a typed MARKREAD event with timestamp: string | null.
  - registerReadMarkerHandlers caches the marker on the matching
    Channel (case-insensitive name match) or PrivateChat (username
    match).  Adds readMarker / readMarkerFetched to the type defs.
  - selectChannel: when the user opens a channel, send MARKREAD
    SET with the latest message timestamp in that channel's history.
  - selectPrivateChat: PMs aren't auto-pushed by the server, so we
    issue a one-shot MARKREAD GET on first open to pick up any
    marker another session set earlier.  Subsequent re-selections
    just push the latest timestamp via SET.
  - MARKREAD_FAIL projection on the FAIL stream so future UI can
    surface server-side rejections without filtering generic FAIL.

* Fix cmd→command after standard-reply merge
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants