Search by

marque / usarrs

lomsoftware

Authentication, user profiles, invites, and admin for Marque

v10.0.0 2026-11-11 07:23 UTC

README

User management — registration, login, roles, invites, profile, 2FA, and passkeys — for the Marque tracker platform.

usarrs owns the entire auth surface end to end: login, registration, password reset, magic links, OAuth (Socialite), logout, session handling, role-based access control, invites, and the admin user panel. It requires laravel/fortify as a hard dependency and uses its action classes for two-factor authentication (passkeys come from laravel/passkeys) — but usarrs is always the only thing that registers /login, /register, and the rest of the auth surface. Fortify's own routes are never reachable (Fortify::ignoreRoutes() is called unconditionally, regardless of any other config); it's used purely as a library.

Starting from scratch?

Usarrs is the auth and user-management half — it has no catalogue and no tracker of its own. For a complete private tracker, install the set:

composer require marque/trove marque/bloodhound marque/guise marque/usarrs marque/cennad

That resolves marque/threepio and marque/deck for you.

Installation

composer require marque/usarrs

Installing marque/usarrs pulls in laravel/fortify transitively — no separate composer require laravel/fortify step needed.

Publish the config, views, and migrations:

php artisan vendor:publish --tag=usarrs-config
php artisan vendor:publish --tag=usarrs-views
php artisan vendor:publish --tag=usarrs-migrations
php artisan migrate

Layout

usarrs' own pages (login, register, profile, admin, and everything else it renders) use config('usarrs.layout'), default deck::layouts.app. Set USARRS_LAYOUT in your .env or publish the config to point to your app's own layout:

USARRS_LAYOUT=layouts.app

This is a per-package setting, not a suite-wide one — guise and disguise have their own equivalent keys (GUISE_LAYOUT, DISGUISE_LAYOUT) that default the same way but are set independently. If you're using more than one Marque frontend package pointed at the same custom layout, set each package's key to match.

Route middleware

Three stacks, because the auth routes divide into three genuinely different audiences:

Config key Default Applies to
guest_middleware ['web', 'guest'] login, register, 2FA challenge, forgot-password
middleware ['web'] reset-password, magic-link, OAuth redirect/callback, the emailed OAuth connection and "confirm it's you" pages; plus auth for OAuth connect and confirm
auth_middleware ['web', 'auth'] logout, profile, email verification, password confirm

The middle row is the one worth understanding. Those routes are not guest-gated on purpose: an authenticated user can legitimately follow a password-reset link that arrived by email, click a magic link issued on another device, or complete an OAuth round trip to connect a provider to their account (see OAuth). Gating them would break all three, which is why guest is applied to the genuinely guest-only routes rather than to the whole group. Each still only exists under the driver it belongs to.

Override any of them by publishing the config. If you point guest_middleware at a custom stack, keep a guest-equivalent in it or logged-in users will see the login form again.

Banned, disabled and pending users

A user whose status is banned, disabled or pending can't sign in, and is signed out if they already are:

  • Every sign-in path refuses them. Password, magic link, OAuth and the two-factor challenge all finish in one place, which turns an inactive user back to the login form with the reason ("This account has been banned."). Passkey sign-in, which doesn't pass through it, is refused (422) before any session exists. A remember-me cookie is caught on Laravel's Login event and gets a 403.
  • A live session ends on its next request, on any page in the web middleware group and not just usarrs' own. usarrs pushes EnsureUserIsActive onto that group, so a ban covers browsing and downloading everywhere. It also covers Livewire actions from a tab that was already open.
  • An API token is refused too. The same check runs in the api group, ordered after authentication, so a token route such as cennad's (['api', 'auth:api']) answers 403 with the reason. Nothing is revoked: reinstate the user and the same token works again. A route outside both groups is not covered; put EnsureUserIsActive on it yourself.

Only the three statuses usarrs defines as inactive are refused: banned, disabled and pending. usarrs adds the status column only if your app doesn't already have one. If yours does, it can hold whatever your app means by it (enabled, 1, your own enum), and none of that is treated as a ban. A user with no status at all is never refused. bloodhound's announce check refuses the same three values, plus an enabled column set to false.

Auth Driver

config('usarrs.auth_driver') controls the top-level login/registration flow. Set via USARRS_AUTH_DRIVER, default password. Exactly one of these four is active at a time:

Driver What it enables What it disables
password Email + password login and registration —
magic_link Passwordless email-only login. A link is emailed on request; visiting it logs the user in Password login and password reset; password-based registration still creates an account, but sign-in afterwards is link-only
socialite OAuth sign-in, via the providers in config('usarrs.socialite_providers') (default ['github']) — plus passkeys, if you've enabled them (see Passkeys). Requires composer require laravel/socialite Password login, password registration, password reset and magic links — refused on the server, not just hidden. See OAuth
invite_only Password login, and registration through an invite Open sign-up: GET /register is a 404 unless it carries a valid, unused, unexpired invite (/register?invite=CODE, the link in the invite email). Needs invites.enabled so members can create invites

The OAuth routes are registered only under socialite. Magic-link verification and password reset are registered under every driver, and answer 404 except under their own: magic links under magic_link, reset under password and invite_only. A route that belongs to another driver is a 404, not a hidden form.

Every driver's GET /login, GET /register etc. are usarrs' own routes. Fortify's independently-registered equivalents are suppressed unconditionally (Fortify::ignoreRoutes(), called in UsarrsServiceProvider::register()) — this holds under every auth_driver value, including invite_only, where a bare POST /register against Fortify's own (unregistered) route correctly 404s/405s rather than silently creating an account underneath the driver's restriction. This matters because every official Laravel starter kit (React, Vue, Livewire) bundles Fortify with its own routes active by default — installing one alongside usarrs, or just having Fortify present for its 2FA actions, used to leave that second front door open. See the manage_auth section below for the full opt-out story.

OAuth (the socialite driver)

An OAuth sign-in is matched to an account by the provider's own user id, stored in usarrs_social_accounts when the two are connected — never by email. A provider reporting someone's email address does not make you that someone.

Someone completes OAuth and… What happens
the identity is connected to an account signed in to that account — through the two-factor challenge if they have 2FA on
it isn't connected, but its email matches an account here (ignoring case) nobody is signed in. That account's own address is emailed a link (unless it already has a proven connection for that provider, or has had three this hour), valid 60 minutes and good once. The email and the page it opens name the provider account; nothing is connected until the holder presses Connect there, which then signs them in (2FA applies). Opening the link alone — or a mail scanner opening it — does nothing
it isn't connected and matches no account an account is created only if registration is open — the same rules as /register, including required invites — then it's sent the verification email and signed in. Where registration is closed, the visitor gets the same answer whether or not the address has an account, so the callback can't be used to probe which addresses exist
they're already signed in, and started a connection from their profile the identity is connected to their account, and they're mailed a notice. Starting one takes a confirmed step-up (see Confirming it's you). One connected to someone else is refused; they are never switched into another account
they're already signed in, and didn't start a connection nothing is connected. A sign-in button pressed while signed in, or a round trip another page started, is refused
they're signed in and started a confirmation the session is confirmed if the identity is connected to them, and nothing is connected either way

Connected accounts. Under socialite the profile page lists the member's connected providers (Profile\ConnectedAccounts, <livewire:usarrs-connected-accounts /> to embed it in your own page), with Connect for the configured providers they haven't connected and Remove for the rest. Both take a confirmed step-up, and both mail the member a notice. Removing is refused when it would leave no way in: the only connection, with no passkey.

Route name Path Purpose
socialite.redirect GET|POST /auth/{provider}/redirect Sign in or up. Only a POST brings an invite or a remember-me choice
socialite.callback GET /auth/{provider}/callback The provider's return
socialite.connect POST /auth/{provider}/connect Connect a provider to the signed-in account (step-up first)
socialite.confirm POST /auth/{provider}/confirm Confirm it's you by signing in again with a connected provider
socialite.link.confirm GET /auth/{provider}/link/{token} The emailed "connect this account?" page (signed)

Invites with OAuth: /register has no password form under this driver. It's the invite's landing page: the invite email's /register?invite=CODE link opens it, with the code filled in (or a field to type one, where invites are required) and a Continue with <provider> button for each provider. The invite travels with that button's POST, the only way one reaches the callback. A GET /auth/{provider}/redirect ignores ?invite=, because a GET can be started by any page, which could plant its own invite in a visitor's sign-up. An invite is claimed in the same transaction that creates the account, so two sign-ups racing on one invite get one account between them.

Remember me is a checkbox beside the provider buttons, off by default as on the password form. It rides the round trip. The emailed connection link above asks it again on its own page, of the inbox owner. Under magic_link the login form's checkbox rides the emailed sign-in link.

Connecting proves the address. An account made by OAuth starts unverified: a provider reporting an address isn't proof of owning it. So someone could make an account under an address that isn't theirs, before its owner does. When the owner later confirms a connection from that address's inbox, usarrs marks the address verified and — because OAuth made the account — removes everything it gained before then: other provider connections, passkeys, two-factor, remembered sign-ins, and any open session (its password hash is rotated; see below). This needs your User model to implement MustVerifyEmail (see Email Verification).

What it can't do is tell an old account from a squatted one. Accounts made before 8.1 — by OAuth, or by /register under another driver before a switch to socialite — have no stored connection, and they're verified on confirmation but keep their 2FA, passkeys and sessions. That protects every existing user's own setup on upgrade day; the cost is that an account somebody squatted before 8.1 keeps what they attached. (Before 8.1 a squatter didn't need to: the callback signed anyone in by email.) If you have reason to doubt an old account, clear its 2FA and passkeys directly — the two_factor_* columns on the user and its rows in passkeys; there's no admin action for it yet.

An account whose address isn't verified can't connect another provider, and changing an account's email on the profile page un-verifies it and sends the new address a verification mail. Addresses are unique ignoring case, on the profile page and /register alike.

Put auth.session on your own signed-in routes. Ending an open session works through Laravel's AuthenticateSession middleware (auth.session), which signs out a session whose password hash has changed underneath it. usarrs puts it on its own signed-in routes and the OAuth routes, adds it to Livewire's persistent middleware (so it also guards component actions in a tab that was already open), and records the hash at every sign-in so no session escapes it. Routes your app defines need it too — Route::middleware(['auth', 'auth.session']) — or a squatter can keep using them.

The cache must be shared and persistent. A pending connection is held in the default cache store between the email and the confirmation, and so is the approval of an emailed "confirm it's you" link. With the array store, or a per-server file store behind a load balancer, every connection link reads as "already used or expired", and an approval made on a phone never reaches the browser that asked.

Upgrading from before 8.1: existing OAuth accounts have no stored connection yet. The first time each of those users signs in with OAuth, they're emailed the connection link above; they confirm on the page it opens, and every sign-in after that goes straight through. The email names the provider account asking to connect, so tell your users to expect it — and to ignore one naming an account that isn't theirs.

Email Verification & Password Confirmation

usarrs registers both of these under the same route names Laravel's own core primitives already expect, so verified and password.confirm middleware — including usarrs' own admin_middleware default, ['web', 'auth', 'verified'] — work exactly as they would in a stock Laravel app:

Route name Path Purpose
verification.notice GET /email/verify "Check your email" prompt
verification.verify GET /email/verify/{id}/{hash} The signed link from the verification email
verification.send POST /email/verification-notification Resend the verification email
password.confirm GET /user/confirm-password Confirm it's you before a sensitive action (see below)
password.confirm.email GET /user/confirm-password/email/{token} The emailed "confirm it's you" page (signed)

Your User model needs implements \Illuminate\Contracts\Auth\MustVerifyEmail to use email verification — not just the trait. This trips people up: Laravel's own base Illuminate\Foundation\Auth\User class uses the MustVerifyEmail trait (the methods), but does not implements the MustVerifyEmail contract (the interface). EnsureEmailIsVerified middleware checks instanceof the contract, so without the explicit implements, the verified middleware silently treats every user as already verified and does nothing — no error, it just never blocks anyone. If you're seeing verification routes work but the verified middleware never actually stopping an unverified user, this is almost certainly why:

class User extends Authenticatable implements MustVerifyEmail
{
    use \Illuminate\Auth\MustVerifyEmail; // the trait — same short name, different thing
}

Every new account is sent the verification email, whether it came from /register or the OAuth callback. Both fire Laravel's Registered event, so your app can hook new accounts. A stock Laravel 11+ app already listens for that event with SendEmailVerificationNotification; when that listener is registered, usarrs leaves the sending to it, and only sends the mail itself when the listener is absent. Either way the user gets exactly one mail.

What needs a verified address. An account nobody has proven the address of may be a squatter's, and anything it attaches outlives the real owner taking the account back. So by default an unverified account can't:

Action Where it's refused
Add a passkey verified middleware on /user/passkeys/options and POST /user/passkeys
Turn on two-factor authentication TwoFactorSetup (enable and confirm)
Connect an OAuth provider while signed in the OAuth callback
Confirm it's you by email the confirm page doesn't offer it, and refuses to send
Create an invite /invites/create redirects to verification.notice; the action refuses too
Get an announce key the Generate/Regenerate action. bloodhound also holds the key back at sign-up and issues it on verification

An app whose User doesn't implement MustVerifyEmail has opted out of verification, and none of these checks apply to it.

Confirming it's you

password.confirm is Laravel's stock middleware and works as it does anywhere: it sends a session that hasn't confirmed recently (auth.password_timeout, 3 hours by default) to /user/confirm-password and brings it back afterwards. It needs no opt-in trait. The page offers whatever the member actually has, because under socialite an account's password is random and unknown, and under magic_link the member never chose one:

Way to confirm Offered when
Their password the driver signs in with passwords (password, invite_only). Under the others a password doesn't confirm, even one set from the profile
Signing in again with a connected provider socialite, for each provider connected to the member. The returning identity must be connected to them
A passkey passkeys are enabled and the member has one
A link to their email address always, once the address is verified. The link opens a page naming the browser that asked; approving there confirms that session, once, from any device. Opening the link approves nothing, so mail scanners can't

usarrs asks for it before adding or removing a passkey, turning two-factor on or off, seeing or regenerating recovery codes, connecting or removing an OAuth provider, and changing the email address or password on /profile/edit. Put password.confirm on your own sensitive routes the same way.

A magic-link sign-in verifies the address it was sent to, as a verification link does. An unverified member who has nothing else to confirm with is pointed to verify first.

The emailed confirmation can be phished. A session that isn't the member's can ask for one, and the link lands in the owner's real inbox. The page it opens names the browser that asked, and says not to approve if that wasn't them.

There's no "sign out my other sessions" yet (#11154). A member who gets a confirmation link or connection notice they didn't ask for should tell staff. Setting or changing the password on /profile/edit (any driver, after a step-up) signs out every other session.

Both are part of the auth surface manage_auth governs — absent entirely when manage_auth=false, same as login/register/2FA/passkey.

Two-Factor Authentication

Off by default (config('usarrs.two_factor.enabled'), USARRS_2FA_ENABLED). An additional factor layered on top of whichever auth_driver is active, not a driver itself — it applies the same way regardless of how the user first authenticated.

Uses Fortify's own TOTP action classes (EnableTwoFactorAuthentication, ConfirmTwoFactorAuthentication, GenerateNewRecoveryCodes, DisableTwoFactorAuthentication) via usarrs' own TwoFactorSetup (profile-mounted) and TwoFactorChallenge (login-time) Livewire components. To use it, your User model needs Laravel\Fortify\TwoFactorAuthenticatable.

Rate limits

Where Limit Keyed on
Password login 5 a minute email (case-folded) and IP
Two-factor challenge 5 a minute, codes and recovery codes together the pending login
Password confirmation 5 a minute the signed-in user
"Confirm it's you" email 1 a minute the signed-in user
OAuth "connect this account?" email 3 an hour; the visitor's answer doesn't change the account it's sent to

The connection-email limit is per account, so anyone with a provider account reporting a member's address can use it up and hold back the member's own link for up to an hour.

Each attempt is counted before the answer is checked, as an atomic increment. Checking first and counting only failures would let a burst of parallel requests through before any of them was counted. For password login, the two-factor challenge and password confirmation, a successful attempt resets the count; the two mail limits never reset early.

The challenge limit isn't keyed on IP. Anyone at the challenge already has the password, and an IP key would let them switch addresses to get five more guesses.

The trade-off is that limits can be used to lock people out.

  • Someone who has a user's password can keep that user out of the two-factor challenge, recovery codes included, for as long as they keep guessing.
  • Someone holding a hijacked session can do the same to password confirmation.
  • Password login is keyed on email and IP. If your app sits behind a proxy you haven't configured TrustProxies for, every visitor shares one IP, and anyone can lock any account out of password login. Configure trusted proxies.

Ten invites a member can email per hour is a limit too: see Invites.

A TOTP code works once. usarrs keeps the timestamp of each user's last accepted code and only accepts a newer one. That way a code seen over someone's shoulder or in a log can't be reused while it's still valid. Fortify's own provider keys this on the code alone, so two users who happen to share a code block each other. usarrs keys it per user.

The limits and the used-code record live in your app's cache store. A cache that doesn't persist between requests, such as the array driver, turns both off.

Passkeys (WebAuthn)

Off by default (config('usarrs.passkeys.enabled'), USARRS_PASSKEYS_ENABLED). Also an additive credential type, not a driver. Uses laravel/passkeys via usarrs' own PasskeyManagement Livewire component. To use it, your User model needs Laravel\Passkeys\PasskeyAuthenticatable and must implement Laravel\Passkeys\Contracts\PasskeyUser.

usarrs registers the passkey endpoints itself, using laravel/passkeys' own controllers, paths and route names:

Route name Path Middleware (plus web, auth.session)
passkey.login-options GET /passkeys/login/options guest, throttle:usarrs-passkeys
passkey.login POST /passkeys/login guest, throttle:usarrs-passkeys
passkey.confirm-options GET /passkeys/confirm/options auth, throttle:usarrs-passkeys
passkey.confirm POST /passkeys/confirm auth, throttle:usarrs-passkeys
passkey.registration-options GET /user/passkeys/options auth, verified, password.confirm, throttle:usarrs-passkeys
passkey.store POST /user/passkeys auth, verified, password.confirm, throttle:usarrs-passkeys
passkey.destroy DELETE /user/passkeys/{passkey} auth, password.confirm

usarrs-passkeys is a named limiter, so it doesn't share a bucket with the app's other throttled routes. It allows 10 requests a minute per user, or per IP for guests, which is five sign-ins (options, then the assertion). Adding or removing a passkey asks the member to confirm it's them (see Confirming it's you), as the DELETE endpoint does; the page sends them to confirm and then returns them.

While usarrs manages auth, laravel/passkeys is never allowed to register them. This works the same on every Fortify version usarrs allows (^1.30). Fortify 1.39 suppresses laravel/passkeys' routes to serve its own, and usarrs suppresses Fortify's, so until this was fixed the endpoints existed nowhere and passkeys didn't work at all (#10883). They're absent when passkeys are off. With manage_auth false, usarrs registers none of them and leaves laravel/passkeys to register its own (or Fortify to suppress them, as 1.39 does).

With passkeys on, the login page shows Sign in with a passkey under every driver. Like every other usarrs sign-in, a passkey sign-in lands on the page the user was sent from, else usarrs.home. A banned or inactive user's passkey is refused (422) before any session exists.

Passkey sign-in is the one login usarrs doesn't finish itself. Every other way in — password, magic link, OAuth, straight after registering — ends in one place that applies the two-factor challenge. A passkey signs in through laravel/passkeys' own endpoint, under every driver including socialite, and asks for no TOTP code afterwards: a passkey is already a phishing-resistant factor, so a code on top adds little. If you want socialite to mean OAuth and nothing else, leave passkeys off.

manage_auth Escape Hatch

config('usarrs.manage_auth'), USARRS_MANAGE_AUTH, default true.

When false, usarrs registers none of its own auth surface — not routes/auth.php (login, register, two-factor-challenge, password reset, magic link, socialite, logout), not the Login/Register/2FA/passkey Livewire components. Not merely a 404 behind a mount-time check: these routes and component tags are never bound in the first place. Fortify's own routes stay suppressed regardless. Roles, invites, admin, profile, and announce-key management are completely unaffected by this flag in either state.

This exists for a power-user building a fully custom login/register/2FA/passkey implementation and needing usarrs to get out of the way entirely. It's a one-way operational decision, not a live toggle — flipping it back to true after building custom auth will silently re-register usarrs' routes/components alongside whatever was built, recreating exactly the kind of route collision this flag exists to prevent. See the upgrade guide before using it.

Roles and the owner

Roles come from trove: user, uploader, moderator, admin, and owner above admin. An owner is an admin for every other purpose: isAdmin() is true for them.

The user screen (admin/users/{user}, under usarrs.prefix) bans, unbans and changes roles under these rules:

  • Admins manage users, uploaders and moderators. They can ban them and move them between those roles.
  • Admins and owners are managed by an owner alone. No admin can demote or ban another admin, or the owner.
  • Only an owner grants Admin or Owner.
  • Nobody changes their own role or bans themselves.
  • The last owner can't be demoted. Make someone else an owner first.

The screen only offers what you may do. The rules live in UserPolicy (ban, unban, setRole) and UserAdminService checks them, so code calling the service gets the same answers if it passes the acting staff member:

$service->setRole($user, Role::Moderator, actor: $request->user());
$service->ban($user, actor: $request->user());

With no actor, the service applies the change without the staff rules. That's for app code and the console, where whoever calls is already trusted. The last-owner rule holds either way.

Each method also takes an optional reason. A ban, unban or role change made by an actor is recorded through trove's StaffActionsInterface (usarrs.user.banned, usarrs.user.unbanned, usarrs.user.role_changed), with the status or role before and after. So is revoking someone else's invite (usarrs.invite.revoked). With no store installed, the record goes nowhere.

Invites follow the same rule: an invite is revoked by its creator, or by anyone who could manage the creator. So only an owner revokes an admin's or an owner's invite.

Setting the first owner

Only an owner grants Admin, so a tracker without one can never make another admin. Set the first from the console:

php artisan usarrs:owner you@example.com

Further owners are made from the user screen.

Invites

'invites' => [
    'enabled' => env('USARRS_INVITES_ENABLED', false),
    'required' => env('USARRS_INVITES_REQUIRED', false),
    'max_per_user' => env('USARRS_MAX_INVITES', 3),
    'expiry_days' => env('USARRS_INVITE_EXPIRY', 7),
],

Two ways to run an invite-gated tracker:

  • auth_driver=invite_only with invites.enabled. /register doesn't exist for anyone without an invite: it's a 404 unless the request carries a valid one. This is the fully closed shape.
  • auth_driver=password with invites.enabled and invites.required. The form is visible to everyone but won't create an account without a valid invite.

An invite created with a recipient address is emailed to that address, with a /register?invite=CODE link (under usarrs.prefix if you set one). A member can email at most ten invites an hour. Without that, creating, revoking and creating again would let anyone send this site's invite email to any address, as often as they liked. Any valid invite presented at registration is used up, whether or not invites.required is on, and the same applies to an OAuth sign-up that carries one.

Dashboard

/dashboard (dashboard.index) is one screen answering "how am I doing" for a signed-in user. The route is registered on every install, whatever else is present, so route('dashboard.index') is safe to link to without a Route::has() guard, unless your app registers its own route at the dashboard's path (see below). A Dashboard entry is added to the navigation for signed-in users, and marque:install offers it as the home page (the default for a private tracker), since marque/marque 1.0.0. Login lands on the page the user was sent to log in from, else on usarrs.home (USARRS_HOME, default /). Set it to /dashboard to send members there when your / is a splash page. Every sign-in follows it, as do registration, password confirmation and email verification. The one exception is connecting a provider while already signed in, which returns to the profile page.

Does your app have its own /dashboard? Laravel's starter kits ship one: Route::get('dashboard', ...)->name('dashboard'). Your app's routes load after usarrs', and two routes on one path can't both exist. Yours replaces usarrs' and the name dashboard.index goes with it. deck leaves the dead nav link out, and usarrs logs an error once a day naming your route. There are two ways out:

  • Drop yours and move what it showed into panels. A panel is a Livewire component plus one registration (trove's README, Dashboard panels). It sits on the dashboard beside usarrs' and the tracker's own panels. Use DashboardPanel::verifiedOnly() for anything your verified routes guard. The dashboard admits unverified members, because the Announce Key panel tells them to verify.
  • Keep yours and move usarrs'. Set usarrs.dashboard.path (USARRS_DASHBOARD_PATH), for example to home. The route name stays dashboard.index, so every link follows it.

The page renders panels from trove's DashboardPanelRegistry. usarrs registers these, each appearing only when it has something true to say:

Panel Appears when
Tracker Stats — uploaded, downloaded, ratio, seed time, leech time, the member's standing (on watch until a date, downloads paused, or exempt), and how many torrents they owe seeding on under hit-and-run, linking to the list a tracker has bound TrackerStatsInterface, and it keeps figures for this user
Announce Key — the key with regenerate, or Generate for a verified user without one, or "verify your address" a tracker is bound and profile.show_announce_key is on
Account Security — two-factor, passkeys two_factor.enabled or passkeys.enabled, and the User model supports it; not registered when manage_auth is false
Invites — allowance, pending, send invites.enabled, and the user can send one or has one outstanding

Other packages add their own panels without usarrs knowing about them — see trove's README. With nothing to show, the page says "Nothing to show yet" rather than rendering an empty grid; that is what a stock install with no tracker and 2FA, passkeys and invites all off will see.

/profile/stats still exists. The dashboard shows the same figures and announce key, and its stats panel links through to it for the full page. Removing it would remove a named route — a major version for usarrs and a broken deep link for anyone who bookmarked it — so in this version the two coexist. Whether it goes is a decision for later, once the dashboard has proven itself.

Seedboxes

When a tracker keeps seedboxes (bloodhound binds trove's SeedboxesInterface), members manage theirs at /profile/seedboxes, linked from /profile/stats. They register an address or a range, see whether it is approved, and remove it. Admins approve and revoke at /admin/seedboxes, which is advertised to the admin panel. Approving is for admins only, not moderators, because an approved seedbox raises the speed limit its member's announces are held to.

The tracker decides what a valid range is and what approval means. usarrs shows its answers, including its reason when it refuses a range. With no such tracker, both routes still exist (a link to them is never fatal) and answer 404, and nothing links to them.

Seeding owed

When a tracker keeps hit-and-run obligations (bloodhound binds trove's SeedingObligationsInterface), /profile/stats lists what the member still owes seeding on. Each torrent shows its seeding so far against what's required, and the ratio where the tracker accepts one. A hit-and-run strike shows when it lapses, and a pre-warning says a strike is coming. Strikes come first. The dashboard's Tracker Stats panel shows the count and links to the list. Nothing shows when nothing is owed or no tracker keeps obligations.

Sessions

When a tracker keeps live sessions (bloodhound binds trove's TrackerSessionsInterface), members see theirs at /profile/sessions, linked from /profile/stats: each torrent they are announcing, seeding or leeching, the client, the address and the last announce, leeching first and 50 to a page. End ends one. It is for a client that restarted somewhere else and is refused by the tracker's location limits while its old session is still live. The tracker decides what ending costs (bloodhound credits nothing the old session moved since its last announce) and refuses to end another member's. With no such tracker the route exists and answers 404, and nothing links to it.

Upgrading from 9.x to 10.0

10.0 makes confirming it's you work for every member, and hardens OAuth around it. Most of it needs nothing from you. Check these:

  1. Published views. If you published any of these, republish them (php artisan vendor:publish --tag=usarrs-views --force, then reapply your changes):
    • usarrs::auth.password-confirm: it offers more than a password now. An old copy leaves socialite and magic-link members at the dead end 10.0 fixes.
    • usarrs::auth.login and usarrs::auth.register: the provider buttons are a POST form (usarrs::auth.partials.oauth-start). An old copy can't carry an invite or remember-me.
    • usarrs::profile.show: it embeds the connected accounts. With an old copy socialite members have no way to connect a provider.
    • usarrs::profile.two-factor-setup: recovery codes show only after a step-up, behind a new button. With an old copy members can't see or regenerate their codes.
    • usarrs::auth.confirm-oauth-link: it asks remember-me and warns before stripping an unproven account.
  2. Your own links into OAuth. A link to socialite.redirect with ?invite= no longer carries the invite: send invitees to /register?invite=CODE, which is what the invite email does. A link meant to connect a provider for a signed-in member needs to go to the profile, or POST to socialite.connect. The callback no longer connects anything a member didn't start there.
  3. Remember-me is off by default on OAuth and magic-link sign-ins, as it always was on the password form. Members who don't tick the box sign in again when their session ends.
  4. Queued mail. The OAuth connection link, the "confirm it's you" link and the connection notices are queued notifications. Run a queue worker, or use the sync queue connection.
  5. Step-ups. Changing the email or password on /profile/edit, connecting or removing an OAuth provider, turning two-factor on or off, and seeing or regenerating recovery codes now ask the member to confirm it's them first. A password no longer counts as that confirmation under magic_link or socialite.

Requirements

  • PHP 8.3+
  • Laravel 13
  • laravel/fortify ^1.30 (pulled in automatically)
  • laravel/passkeys (pulled in automatically; only used if passkeys are enabled)
  • Livewire 4 (pulled in automatically)

License

MIT. See LICENSE.