Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 0 additions & 51 deletions app/Concerns/ProfileValidationRules.php

This file was deleted.

9 changes: 9 additions & 0 deletions app/Http/Controllers/Auth/AccountsSsoController.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ public function callback(Request $request): RedirectResponse
return $this->failed(__('3AG Accounts did not return an email address.'));
}

// Accounts will not issue a code for an unverified address, so this
// should never fire. It is here because the address is what decides
// which invitations the signer can accept and which pre-existing
// account they adopt below, and that is too much to rest on the
// identity provider alone holding its end up.
if ($accountsUser->emailVerified === false) {
return $this->failed(__('Your 3AG Accounts email address is not verified yet. Verify it and sign in again.'));
}

$user = $this->link($accountsUser, Str::lower($email));

Auth::login($user, remember: true);
Expand Down
19 changes: 0 additions & 19 deletions app/Http/Controllers/Settings/ProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
use App\Actions\Organizations\HandOverOwnedOrganizations;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\ProfileDeleteRequest;
use App\Http\Requests\Settings\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
Expand All @@ -24,24 +23,6 @@ public function edit(Request $request): Response
]);
}

/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());

if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}

$request->user()->save();

Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);

return to_route('profile.edit');
}

/**
* Delete the user's profile.
*/
Expand Down
22 changes: 0 additions & 22 deletions app/Http/Requests/Settings/ProfileUpdateRequest.php

This file was deleted.

21 changes: 21 additions & 0 deletions app/Services/Auth/AccountsOidc.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,30 @@ private function claims(string $accessToken): AccountsUser
id: $subject,
email: is_string($email) ? $email : null,
name: is_string($name) ? $name : null,
emailVerified: $this->emailVerified($claims),
);
}

/**
* Read the email_verified claim, if the issuer sent one.
*
* The claim is optional in OIDC, so its absence is not a denial -- it
* means the issuer did not say, and null keeps that distinct from an
* explicit false the caller should refuse. Accounts sends a JSON bool;
* the filter also copes with the "true"/"false" strings some issuers
* send, and answers null for anything it cannot read either way.
*
* @param array<string, mixed> $claims
*/
private function emailVerified(array $claims): ?bool
{
if (! array_key_exists('email_verified', $claims)) {
return null;
}

return filter_var($claims['email_verified'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}

/**
* Get one endpoint from the issuer's discovery document.
*
Expand Down
6 changes: 6 additions & 0 deletions app/Services/Auth/AccountsUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@
*/
class AccountsUser
{
/**
* @param bool|null $emailVerified Null when the issuer sent no
* email_verified claim, which OIDC
* allows: not said, rather than no.
*/
public function __construct(
public readonly string $id,
public readonly ?string $email,
public readonly ?string $name,
public readonly ?bool $emailVerified = null,
) {}
}
127 changes: 52 additions & 75 deletions resources/js/pages/settings/profile.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { Form, Head, usePage } from '@inertiajs/react';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import { Head, usePage } from '@inertiajs/react';
import { ExternalLink } from 'lucide-react';
import DeleteUser from '@/components/delete-user';
import Heading from '@/components/heading';
import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { edit } from '@/routes/profile';
import type { Auth } from '@/types';
Expand All @@ -14,6 +12,14 @@ type PageProps = {
accountsUrl: string;
};

/**
* A read-only view of the identity 3AG Accounts holds.
*
* Nothing here is editable because nothing here was ever the local app's to
* change: single sign-on rewrites the name and email address from the
* Accounts claims on every login, so a local edit only ever lasted until the
* user signed in again.
*/
export default function Profile() {
const { auth, accountsUrl } = usePage<PageProps>().props;

Expand All @@ -27,82 +33,53 @@ export default function Profile() {
<Heading
variant="small"
title="Profile"
description="Update your name and email address"
description="Your name and email address come from 3AG Accounts"
/>

<Form
{...ProfileController.update.form()}
options={{
preserveScroll: true,
}}
className="space-y-6"
>
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<dl className="space-y-6">
<div className="grid gap-2">
<Label asChild>
<dt>Name</dt>
</Label>
<dd
className="text-muted-foreground text-sm"
data-test="profile-name"
>
{auth.user.name}
</dd>
</div>

<Input
id="name"
className="mt-1 block w-full"
defaultValue={auth.user.name}
name="name"
required
autoComplete="name"
placeholder="Full name"
/>
<div className="grid gap-2">
<Label asChild>
<dt>Email address</dt>
</Label>
<dd
className="text-muted-foreground text-sm"
data-test="profile-email"
>
{auth.user.email}
</dd>
</div>
</dl>

<InputError
className="mt-2"
message={errors.name}
/>
</div>
<p className="text-muted-foreground text-sm">
Your name, email address, password, two-factor
authentication, and passkeys are all managed in 3AG
Accounts. Changes there apply to every 3AG app the next time
you sign in.
</p>

<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>

<Input
id="email"
type="email"
className="mt-1 block w-full"
defaultValue={auth.user.email}
name="email"
required
autoComplete="username"
placeholder="Email address"
/>

<InputError
className="mt-2"
message={errors.email}
/>
</div>

<p className="text-muted-foreground text-sm">
Password, two-factor authentication, and
passkeys are managed in{' '}
<a
href={accountsUrl}
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors hover:decoration-current"
target="_blank"
rel="noreferrer"
>
3AG Accounts
</a>
.
</p>

<div className="flex items-center gap-4">
<Button
disabled={processing}
data-test="update-profile-button"
>
Save
</Button>
</div>
</>
)}
</Form>
<Button variant="secondary" asChild>
<a
href={accountsUrl}
target="_blank"
rel="noreferrer"
data-test="accounts-link"
>
Manage in 3AG Accounts
<ExternalLink className="size-4" />
</a>
</Button>
</div>

<DeleteUser />
Expand Down
1 change: 0 additions & 1 deletion routes/settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
Route::redirect('settings', '/settings/profile');

Route::get('settings/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('settings/profile', [ProfileController::class, 'update'])->name('profile.update');
});

Route::middleware(['auth', 'verified'])->group(function () {
Expand Down
30 changes: 30 additions & 0 deletions tests/Browser/Settings/ProfileTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

use App\Models\User;

beforeEach(function () {
$this->user = User::factory()->create([
'name' => 'Ada Lovelace',
'email' => 'ada@3ag.local',
]);

$this->actingAs($this->user);
});

it('shows the identity Accounts holds without offering to edit it', function () {
$page = visit(route('profile.edit'));

$page->assertSee('Ada Lovelace')
->assertSee('ada@3ag.local')
->assertNoJavaScriptErrors();

// Nothing to type into and nothing to submit: the only inputs left on the
// page belong to the delete-account dialog, which is still the user's own.
expect($page->script('document.querySelectorAll(\'input[name="email"], input[name="name"]\').length'))->toBe(0);
});

it('points the user at Accounts to change any of it', function () {
visit(route('profile.edit'))
->assertAttribute('@accounts-link', 'href', rtrim((string) config('oidc.connections.accounts.base_url'), '/'))
->assertNoJavaScriptErrors();
});
Loading
Loading