create-nevela 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.mjs +9 -0
- package/package.json +1 -1
- package/template/laravel/composer.json +1 -0
- package/template/laravel/config/nevela.php +25 -0
- package/template/laravel/database/migrations/2026_10_18_000000_create_nevela_social_accounts_table.php +35 -0
- package/template/laravel/src/Access/ClosedAccounts.php +1 -1
- package/template/laravel/src/Auth/Social.php +120 -0
- package/template/laravel/src/Console/StatusCommand.php +12 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +8 -2
- package/template/laravel/src/Http/Auth/Answers.php +3 -2
- package/template/laravel/src/Http/Auth/SignInController.php +3 -0
- package/template/laravel/src/Http/Auth/SocialController.php +285 -0
- package/template/laravel/src/Nevela.php +1 -1
- package/template/laravel/src/NevelaServiceProvider.php +7 -0
- package/template/web/app/api/auth/[...action]/route.ts +67 -0
- package/template/web/app/dashboard/account/layout.tsx +3 -2
- package/template/web/app/dashboard/account/security/page.tsx +16 -4
- package/template/web/app/sign-in/page.tsx +20 -4
- package/template/web/app/sign-up/page.tsx +3 -2
- package/template/web/components/account/sections.tsx +3 -3
- package/template/web/lib/auth-client.ts +22 -5
- package/template/web/lib/auth-config.ts +5 -2
- package/template/web/lib/social.ts +22 -0
- package/template/web/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -739,6 +739,15 @@ async function main() {
|
|
|
739
739
|
if (fs.existsSync(env) && !/^NEVELA_PROXY_SECRET=/m.test(fs.readFileSync(env, 'utf8'))) {
|
|
740
740
|
fs.appendFileSync(env, `\n# The same value as in apps/web/.env.local.\nNEVELA_PROXY_SECRET=${proxySecret}\n`);
|
|
741
741
|
}
|
|
742
|
+
// Where to switch on Google and GitHub, and the keys they need, so nobody has to look the names up.
|
|
743
|
+
if (fs.existsSync(env) && !/^SOCIAL_AUTH_ENABLED=/m.test(fs.readFileSync(env, 'utf8'))) {
|
|
744
|
+
fs.appendFileSync(
|
|
745
|
+
env,
|
|
746
|
+
'\n# Sign in with Google and GitHub: https://nevela-docs.vercel.app/guides/authentication/#google-and-github\n' +
|
|
747
|
+
'# Off until this is true. A provider shows once both its keys are set.\n' +
|
|
748
|
+
'SOCIAL_AUTH_ENABLED=false\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\nGITHUB_CLIENT_ID=\nGITHUB_CLIENT_SECRET=\n',
|
|
749
|
+
);
|
|
750
|
+
}
|
|
742
751
|
const sqlite = path.join(api, 'database', 'database.sqlite');
|
|
743
752
|
if (/^DB_CONNECTION=sqlite\s*$/m.test(fs.readFileSync(env, 'utf8')) && !fs.existsSync(sqlite)) fs.writeFileSync(sqlite, '');
|
|
744
753
|
return addApiTokens(path.join(api, 'app', 'Models', 'User.php'));
|
package/package.json
CHANGED
|
@@ -54,6 +54,31 @@ return [
|
|
|
54
54
|
*/
|
|
55
55
|
'registration' => (bool) env('NEVELA_REGISTRATION', false),
|
|
56
56
|
|
|
57
|
+
/*
|
|
58
|
+
| Signing in with Google or GitHub (Laravel Socialite). SOCIAL_AUTH_ENABLED is the
|
|
59
|
+
| switch for the whole app: off, no button shows and the routes refuse. On, a provider
|
|
60
|
+
| shows once its two keys are set. The dashboard asks on every page, so the switch
|
|
61
|
+
| needs no rebuild, only `php artisan config:clear` where the config is cached.
|
|
62
|
+
|
|
|
63
|
+
| Give the provider this address to send people back to:
|
|
64
|
+
| {NEVELA_WEB_URL}/api/auth/oauth/{provider}/callback
|
|
65
|
+
|
|
|
66
|
+
| Who may come in follows `registration`. Off: only someone with an account, found by
|
|
67
|
+
| the provider account connected to it or by an address the provider says is
|
|
68
|
+
| verified. On: anyone, and a new account is made for a new address.
|
|
69
|
+
*/
|
|
70
|
+
'social' => [
|
|
71
|
+
'enabled' => (bool) env('SOCIAL_AUTH_ENABLED', false),
|
|
72
|
+
'google' => [
|
|
73
|
+
'client_id' => env('GOOGLE_CLIENT_ID'),
|
|
74
|
+
'client_secret' => env('GOOGLE_CLIENT_SECRET'),
|
|
75
|
+
],
|
|
76
|
+
'github' => [
|
|
77
|
+
'client_id' => env('GITHUB_CLIENT_ID'),
|
|
78
|
+
'client_secret' => env('GITHUB_CLIENT_SECRET'),
|
|
79
|
+
],
|
|
80
|
+
],
|
|
81
|
+
|
|
57
82
|
/* A sign-in link by email. */
|
|
58
83
|
'magic_link' => true,
|
|
59
84
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\Schema;
|
|
6
|
+
|
|
7
|
+
// Which Google or GitHub account belongs to which account here.
|
|
8
|
+
//
|
|
9
|
+
// Matched by the provider's own id for the person, not by email address: the address at
|
|
10
|
+
// Google can change, and a later sign-in still finds the account. The address is kept
|
|
11
|
+
// only to show on the Account page.
|
|
12
|
+
return new class extends Migration
|
|
13
|
+
{
|
|
14
|
+
public function up(): void
|
|
15
|
+
{
|
|
16
|
+
if (Schema::hasTable('nevela_social_accounts')) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
Schema::create('nevela_social_accounts', function (Blueprint $table) {
|
|
20
|
+
$table->uuid('id')->primary();
|
|
21
|
+
// Text, as the roles table keeps them: an account's id is a number or a UUID.
|
|
22
|
+
$table->string('user_id', 64)->index();
|
|
23
|
+
$table->string('provider', 32);
|
|
24
|
+
$table->string('provider_id', 191);
|
|
25
|
+
$table->string('email')->nullable();
|
|
26
|
+
$table->timestamps();
|
|
27
|
+
$table->unique(['provider', 'provider_id']);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public function down(): void
|
|
32
|
+
{
|
|
33
|
+
Schema::dropIfExists('nevela_social_accounts');
|
|
34
|
+
}
|
|
35
|
+
};
|
|
@@ -195,7 +195,7 @@ final class ClosedAccounts
|
|
|
195
195
|
DB::transaction(function () use ($user, $key) {
|
|
196
196
|
Account::revokeTokens($user);
|
|
197
197
|
DB::table('nevela_role_user')->where('user_id', $key)->delete();
|
|
198
|
-
foreach (['nevela_two_factor', 'nevela_passkeys'] as $table) {
|
|
198
|
+
foreach (['nevela_two_factor', 'nevela_passkeys', 'nevela_social_accounts'] as $table) {
|
|
199
199
|
if (Schema::hasTable($table)) {
|
|
200
200
|
DB::table($table)->where('user_id', $key)->delete();
|
|
201
201
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\Request;
|
|
6
|
+
use Laravel\Socialite\Contracts\User as ProviderUser;
|
|
7
|
+
use Laravel\Socialite\Facades\Socialite;
|
|
8
|
+
use Laravel\Socialite\Two\AbstractProvider;
|
|
9
|
+
use Laravel\Socialite\Two\GithubProvider;
|
|
10
|
+
use Laravel\Socialite\Two\GoogleProvider;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Signing in with Google or GitHub, through Laravel Socialite.
|
|
14
|
+
*
|
|
15
|
+
* One switch turns it on or off for the whole app (SOCIAL_AUTH_ENABLED), and a provider
|
|
16
|
+
* is offered once its two keys are set. Off, no button shows and the routes refuse.
|
|
17
|
+
*
|
|
18
|
+
* The browser never talks to Laravel here. The dashboard's server asks for the provider's
|
|
19
|
+
* address, and the provider sends the browser back to the dashboard, whose server hands
|
|
20
|
+
* the code to Laravel. So the address registered with the provider is the dashboard's:
|
|
21
|
+
*
|
|
22
|
+
* {NEVELA_WEB_URL}/api/auth/oauth/{provider}/callback
|
|
23
|
+
*/
|
|
24
|
+
final class Social
|
|
25
|
+
{
|
|
26
|
+
/** The providers Nevela knows, and Socialite's class for each. */
|
|
27
|
+
public const PROVIDERS = [
|
|
28
|
+
'google' => GoogleProvider::class,
|
|
29
|
+
'github' => GithubProvider::class,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
public const LABELS = ['google' => 'Google', 'github' => 'GitHub'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Stands in for the provider in tests, which have no Google to ask: given the provider
|
|
36
|
+
* and the code, the person it would have answered with.
|
|
37
|
+
*
|
|
38
|
+
* @var (callable(string, string): ProviderUser)|null
|
|
39
|
+
*/
|
|
40
|
+
public static $fake = null;
|
|
41
|
+
|
|
42
|
+
/** Whether social sign-in is switched on at all. */
|
|
43
|
+
public static function enabled(): bool
|
|
44
|
+
{
|
|
45
|
+
return (bool) config('nevela.auth.social.enabled', false);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The providers people can use: switched on, and with both keys.
|
|
50
|
+
*
|
|
51
|
+
* @return list<string>
|
|
52
|
+
*/
|
|
53
|
+
public static function available(): array
|
|
54
|
+
{
|
|
55
|
+
if (! self::enabled()) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return array_values(array_filter(array_keys(self::PROVIDERS), fn (string $provider) => self::configured($provider)));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether a provider has its keys, whether or not the switch is on. */
|
|
63
|
+
public static function configured(string $provider): bool
|
|
64
|
+
{
|
|
65
|
+
$keys = (array) config("nevela.auth.social.{$provider}", []);
|
|
66
|
+
|
|
67
|
+
return isset(self::PROVIDERS[$provider]) && (string) ($keys['client_id'] ?? '') !== '' && (string) ($keys['client_secret'] ?? '') !== '';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Where the provider sends the browser back to: a page of the dashboard. */
|
|
71
|
+
public static function callback(string $provider, ?Request $request = null): string
|
|
72
|
+
{
|
|
73
|
+
return Web::url("/api/auth/oauth/{$provider}/callback", [], $request);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Socialite's provider, set up from config/nevela.php, with no session: the state is ours. */
|
|
77
|
+
public static function driver(string $provider, string $callback): AbstractProvider
|
|
78
|
+
{
|
|
79
|
+
$keys = (array) config("nevela.auth.social.{$provider}", []);
|
|
80
|
+
|
|
81
|
+
/** @var AbstractProvider $driver */
|
|
82
|
+
$driver = Socialite::buildProvider(self::PROVIDERS[$provider], [
|
|
83
|
+
'client_id' => (string) ($keys['client_id'] ?? ''),
|
|
84
|
+
'client_secret' => (string) ($keys['client_secret'] ?? ''),
|
|
85
|
+
'redirect' => $callback,
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
return $driver->stateless();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The address to send the browser to, carrying our state. */
|
|
92
|
+
public static function authorizationUrl(string $provider, string $callback, string $state): string
|
|
93
|
+
{
|
|
94
|
+
return self::driver($provider, $callback)->with(['state' => $state])->redirect()->getTargetUrl();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Who the provider says this is, from the code it sent back.
|
|
99
|
+
*
|
|
100
|
+
* @return array{id: string, email: string|null, verified: bool, name: string|null}
|
|
101
|
+
*/
|
|
102
|
+
public static function profile(string $provider, string $callback, string $code): array
|
|
103
|
+
{
|
|
104
|
+
$user = self::$fake !== null ? (self::$fake)($provider, $code) : self::driver($provider, $callback)->user();
|
|
105
|
+
$raw = method_exists($user, 'getRaw') ? (array) $user->getRaw() : [];
|
|
106
|
+
$email = $user->getEmail();
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
'id' => (string) $user->getId(),
|
|
110
|
+
'email' => is_string($email) && $email !== '' ? mb_strtolower(trim($email)) : null,
|
|
111
|
+
// Google says, of each address. GitHub only gives an address that is verified:
|
|
112
|
+
// the public one has to be, and Socialite otherwise asks for the primary verified one.
|
|
113
|
+
'verified' => match ($provider) {
|
|
114
|
+
'google' => ($raw['email_verified'] ?? $raw['verified_email'] ?? false) === true,
|
|
115
|
+
default => is_string($email) && $email !== '',
|
|
116
|
+
},
|
|
117
|
+
'name' => $user->getName() ?: $user->getNickname(),
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -7,6 +7,7 @@ use Illuminate\Support\Facades\DB;
|
|
|
7
7
|
use Illuminate\Support\Facades\Http;
|
|
8
8
|
use Illuminate\Support\Facades\Schema;
|
|
9
9
|
use Nevela\Laravel\Access\ClosedAccounts;
|
|
10
|
+
use Nevela\Laravel\Auth\Social;
|
|
10
11
|
use Nevela\Laravel\Forms\FormGuard;
|
|
11
12
|
use Nevela\Laravel\Nevela;
|
|
12
13
|
use Nevela\Laravel\Support\DashboardState;
|
|
@@ -109,6 +110,17 @@ final class StatusCommand extends Command
|
|
|
109
110
|
});
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// --- social sign-in ---
|
|
114
|
+
if (Social::enabled()) {
|
|
115
|
+
$missing = array_values(array_filter(array_keys(Social::PROVIDERS), fn (string $provider) => ! Social::configured($provider)));
|
|
116
|
+
$row('Social sign-in', Social::available() === []
|
|
117
|
+
? '<fg=yellow>switched on, and no provider has its keys: set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, or GitHub\'s</>'
|
|
118
|
+
: 'on: '.implode(', ', array_map(fn (string $provider) => Social::LABELS[$provider], Social::available()))
|
|
119
|
+
.($missing === [] ? '' : ' <fg=gray>('.implode(', ', array_map(fn (string $provider) => Social::LABELS[$provider], $missing)).' has no keys)</>'));
|
|
120
|
+
} else {
|
|
121
|
+
$row('Social sign-in', '<fg=gray>off (SOCIAL_AUTH_ENABLED)</>');
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
// --- dashboard ---
|
|
113
125
|
$web = config('nevela.web_path');
|
|
114
126
|
if (! $web || ! is_dir($web)) {
|
|
@@ -447,7 +447,10 @@ final class ResourceGenerator
|
|
|
447
447
|
checkBreachedPasswords: {$flag('check_breached_passwords')},
|
|
448
448
|
/** People can close their own account, from the Account page. */
|
|
449
449
|
closeAccount: {$flag('close_account')},
|
|
450
|
-
/**
|
|
450
|
+
/**
|
|
451
|
+
* Always empty here. Which of Google and GitHub show is asked of Laravel on each
|
|
452
|
+
* page, so SOCIAL_AUTH_ENABLED needs no generate or rebuild: see lib/social.ts.
|
|
453
|
+
*/
|
|
451
454
|
social: [] as SocialProvider[],
|
|
452
455
|
};
|
|
453
456
|
{$e}
|
|
@@ -455,7 +458,10 @@ final class ResourceGenerator
|
|
|
455
458
|
/** Whether any second factor can be set up. */
|
|
456
459
|
export const twoFactorAvailable = authConfig.twoFactor.authenticator || authConfig.twoFactor.email;
|
|
457
460
|
|
|
458
|
-
/**
|
|
461
|
+
/**
|
|
462
|
+
* @deprecated Kept so a page written before 0.10 still builds. It shows no providers:
|
|
463
|
+
* use `enabledSocialProviders` from "@/lib/social", which asks Laravel.
|
|
464
|
+
*/
|
|
459
465
|
export function enabledSocialProviders(): SocialProvider[] {
|
|
460
466
|
return authConfig.social;
|
|
461
467
|
}
|
|
@@ -10,9 +10,10 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
|
10
10
|
/** What the auth controllers share: how they refuse, and how they check a setting or a password. */
|
|
11
11
|
trait Answers
|
|
12
12
|
{
|
|
13
|
-
|
|
13
|
+
/** @param array<string, mixed> $more Anything else the client needs with it, such as where to go next */
|
|
14
|
+
private function refuse(string $code, string $message, int $status = 400, array $more = []): JsonResponse
|
|
14
15
|
{
|
|
15
|
-
return response()->json(['error' => $message, 'code' => $code], $status);
|
|
16
|
+
return response()->json(['error' => $message, 'code' => $code] + $more, $status);
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/** A method switched off in config/nevela.php is refused, not just hidden. */
|
|
@@ -11,6 +11,7 @@ use Nevela\Laravel\Auth\Account;
|
|
|
11
11
|
use Nevela\Laravel\Auth\AuthMail;
|
|
12
12
|
use Nevela\Laravel\Auth\Challenges;
|
|
13
13
|
use Nevela\Laravel\Auth\Passkeys;
|
|
14
|
+
use Nevela\Laravel\Auth\Social;
|
|
14
15
|
use Nevela\Laravel\Auth\Web;
|
|
15
16
|
use Nevela\Laravel\Models\TwoFactor;
|
|
16
17
|
use RuntimeException;
|
|
@@ -51,6 +52,8 @@ final class SignInController
|
|
|
51
52
|
],
|
|
52
53
|
'requireEmailVerification' => (bool) config('nevela.auth.require_email_verification', false),
|
|
53
54
|
'closeAccount' => (bool) config('nevela.auth.close_account', true),
|
|
55
|
+
// Switched on, and with their keys: the buttons to draw.
|
|
56
|
+
'social' => Social::available(),
|
|
54
57
|
];
|
|
55
58
|
}
|
|
56
59
|
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Http\Auth;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\JsonResponse;
|
|
6
|
+
use Illuminate\Http\Request;
|
|
7
|
+
use Illuminate\Http\Response;
|
|
8
|
+
use Illuminate\Support\Facades\DB;
|
|
9
|
+
use Illuminate\Support\Facades\Hash;
|
|
10
|
+
use Illuminate\Support\Facades\Schema;
|
|
11
|
+
use Illuminate\Support\Str;
|
|
12
|
+
use Nevela\Laravel\Access\Access;
|
|
13
|
+
use Nevela\Laravel\Access\ClosedAccounts;
|
|
14
|
+
use Nevela\Laravel\Auth\Account;
|
|
15
|
+
use Nevela\Laravel\Auth\Challenges;
|
|
16
|
+
use Nevela\Laravel\Auth\Social;
|
|
17
|
+
use Nevela\Laravel\Auth\Web;
|
|
18
|
+
use Nevela\Laravel\Support\Key;
|
|
19
|
+
use Throwable;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Signing in with Google or GitHub, and connecting them to an account (see Auth\Social).
|
|
23
|
+
*
|
|
24
|
+
* POST auth/oauth/{provider}/start → { url, state } where to send the browser
|
|
25
|
+
* POST auth/oauth/{provider}/callback { state, code } → a token, a second step, or { linked }
|
|
26
|
+
* GET auth/social the signed-in person's connected providers
|
|
27
|
+
* DELETE auth/social/{id} disconnect one
|
|
28
|
+
*
|
|
29
|
+
* The first two are called by the dashboard's server, never by the browser.
|
|
30
|
+
*
|
|
31
|
+
* Who may come in this way follows `registration`. Off, only someone who already has an
|
|
32
|
+
* account: matched by the provider account connected to it, or by an address the provider
|
|
33
|
+
* says is verified. On, anyone else gets a new account as well.
|
|
34
|
+
*/
|
|
35
|
+
final class SocialController
|
|
36
|
+
{
|
|
37
|
+
use Answers;
|
|
38
|
+
|
|
39
|
+
/** How long the provider's page may take: the state is good for this long, once. */
|
|
40
|
+
private const STATE_SECONDS = 600;
|
|
41
|
+
|
|
42
|
+
private const TABLE = 'nevela_social_accounts';
|
|
43
|
+
|
|
44
|
+
public function start(Request $request, string $provider): JsonResponse
|
|
45
|
+
{
|
|
46
|
+
if (($refused = $this->unavailable($provider)) !== null) {
|
|
47
|
+
return $refused;
|
|
48
|
+
}
|
|
49
|
+
$input = $request->validate(['next' => ['nullable', 'string', 'max:2000'], 'link' => ['nullable', 'boolean']]);
|
|
50
|
+
$linking = (bool) ($input['link'] ?? false);
|
|
51
|
+
$user = $linking ? $request->user('sanctum') : null;
|
|
52
|
+
if ($linking && $user === null) {
|
|
53
|
+
return $this->refuse('UNAUTHENTICATED', 'Sign in first, then connect it from your account.', 401);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
$callback = Social::callback($provider, $request);
|
|
57
|
+
$state = Challenges::start('oauth', [
|
|
58
|
+
'provider' => $provider,
|
|
59
|
+
'callback' => $callback,
|
|
60
|
+
'next' => Web::path($input['next'] ?? null, $linking ? '/dashboard/account/security' : '/dashboard'),
|
|
61
|
+
'user' => $user === null ? null : (string) $user->getAuthIdentifier(),
|
|
62
|
+
'device' => $request->input('deviceName'),
|
|
63
|
+
], self::STATE_SECONDS);
|
|
64
|
+
|
|
65
|
+
return response()->json(['url' => Social::authorizationUrl($provider, $callback, $state), 'state' => $state]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
public function callback(Request $request, string $provider): JsonResponse
|
|
69
|
+
{
|
|
70
|
+
if (($refused = $this->unavailable($provider)) !== null) {
|
|
71
|
+
return $refused;
|
|
72
|
+
}
|
|
73
|
+
// Good once, and only for the provider it was made for: a callback can't be replayed,
|
|
74
|
+
// and can't be one that this app didn't start.
|
|
75
|
+
$pending = Challenges::take('oauth', (string) $request->input('state'));
|
|
76
|
+
if ($pending === null || ($pending['provider'] ?? null) !== $provider) {
|
|
77
|
+
return $this->refuse('OAUTH_STATE', 'That sign-in took too long or was already used. Start again.', 401);
|
|
78
|
+
}
|
|
79
|
+
$next = (string) ($pending['next'] ?? '/dashboard');
|
|
80
|
+
$label = Social::LABELS[$provider];
|
|
81
|
+
if ($request->filled('error')) {
|
|
82
|
+
return $this->refuse('OAUTH_DENIED', "{$label} sign-in was cancelled.", 401, ['next' => $next]);
|
|
83
|
+
}
|
|
84
|
+
$code = (string) $request->input('code');
|
|
85
|
+
if ($code === '') {
|
|
86
|
+
return $this->refuse('OAUTH_FAILED', "{$label} didn't say who you are. Try again.", 401, ['next' => $next]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
$profile = Social::profile($provider, (string) $pending['callback'], $code);
|
|
91
|
+
} catch (Throwable $e) {
|
|
92
|
+
report($e);
|
|
93
|
+
|
|
94
|
+
return $this->refuse('OAUTH_FAILED', "{$label} sign-in didn't finish. Try again.", 401, ['next' => $next]);
|
|
95
|
+
}
|
|
96
|
+
if ($profile['email'] === null) {
|
|
97
|
+
return $this->refuse('NO_EMAIL', "Your {$label} account didn't share an email address, so it can't be matched to an account.", 422, ['next' => $next]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Connecting it to the account that asked, from the Account page.
|
|
101
|
+
if (($pending['user'] ?? null) !== null) {
|
|
102
|
+
return $this->connect($provider, $profile, (string) $pending['user'], $next);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
$user = $this->linked($provider, $profile['id']);
|
|
106
|
+
if ($user === null) {
|
|
107
|
+
if (! $profile['verified']) {
|
|
108
|
+
return $this->refuse('EMAIL_NOT_VERIFIED', "Verify your email address with {$label} first, then try again.", 422, ['next' => $next]);
|
|
109
|
+
}
|
|
110
|
+
$user = Account::findByEmail($profile['email']);
|
|
111
|
+
if ($user === null) {
|
|
112
|
+
$made = $this->register($profile);
|
|
113
|
+
if ($made instanceof JsonResponse) {
|
|
114
|
+
return $made;
|
|
115
|
+
}
|
|
116
|
+
$user = $made;
|
|
117
|
+
} elseif ($user->email_verified_at === null) {
|
|
118
|
+
$this->reclaim($user);
|
|
119
|
+
}
|
|
120
|
+
if (! $this->link($provider, $profile, $user)) {
|
|
121
|
+
return $this->refuse('ALREADY_LINKED', "That {$label} account is connected to another account here.", 409, ['next' => $next]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// The provider has seen this address work.
|
|
125
|
+
if ($profile['verified'] && $user->email_verified_at === null && mb_strtolower((string) $user->email) === $profile['email']) {
|
|
126
|
+
$user->forceFill(['email_verified_at' => now()])->save();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
$request->merge(['deviceName' => $pending['device'] ?? null]);
|
|
130
|
+
// The same as after a password: an account with a second step still owes it.
|
|
131
|
+
$answer = SignInController::finish($user, $request);
|
|
132
|
+
$answer->setData(['next' => $next] + (array) $answer->getData(true));
|
|
133
|
+
|
|
134
|
+
return $answer;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** GET auth/social: the providers connected to the signed-in account. */
|
|
138
|
+
public function accounts(Request $request): JsonResponse
|
|
139
|
+
{
|
|
140
|
+
if (! Schema::hasTable(self::TABLE)) {
|
|
141
|
+
return response()->json(['data' => []]);
|
|
142
|
+
}
|
|
143
|
+
$rows = DB::table(self::TABLE)->where('user_id', (string) $request->user()->getAuthIdentifier())->orderBy('created_at')->get();
|
|
144
|
+
|
|
145
|
+
return response()->json(['data' => $rows->map(fn ($row) => [
|
|
146
|
+
'id' => (string) $row->id,
|
|
147
|
+
'provider' => (string) $row->provider,
|
|
148
|
+
'email' => $row->email,
|
|
149
|
+
'connectedAt' => $row->created_at,
|
|
150
|
+
])->all()]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* DELETE auth/social/{id}. Always allowed: every account has an email address, and so a
|
|
155
|
+
* way back in by password reset, a sign-in link or a code, whichever are switched on.
|
|
156
|
+
*/
|
|
157
|
+
public function disconnect(Request $request, string $id): Response
|
|
158
|
+
{
|
|
159
|
+
if (Key::isUuid($id) && Schema::hasTable(self::TABLE)) {
|
|
160
|
+
DB::table(self::TABLE)->where('id', $id)->where('user_id', (string) $request->user()->getAuthIdentifier())->delete();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return response()->noContent();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Refused when the switch is off, the provider is unknown, or it has no keys. */
|
|
167
|
+
private function unavailable(string $provider): ?JsonResponse
|
|
168
|
+
{
|
|
169
|
+
return in_array($provider, Social::available(), true)
|
|
170
|
+
? null
|
|
171
|
+
: $this->refuse('NOT_ENABLED', "Signing in with that provider isn't switched on for this app.", 404);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The account a provider's account is connected to. */
|
|
175
|
+
private function linked(string $provider, string $providerId): mixed
|
|
176
|
+
{
|
|
177
|
+
if (! Schema::hasTable(self::TABLE)) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
$row = DB::table(self::TABLE)->where('provider', $provider)->where('provider_id', $providerId)->first();
|
|
181
|
+
if ($row === null) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
$user = Account::model()::query()->find($row->user_id);
|
|
185
|
+
// Connected to an account that is gone: the connection goes too, so it can be made again.
|
|
186
|
+
if ($user === null) {
|
|
187
|
+
DB::table(self::TABLE)->where('id', $row->id)->delete();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return $user;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Connect a provider account to this one. False when it is connected to another account,
|
|
195
|
+
* as when two requests raced to connect it.
|
|
196
|
+
*
|
|
197
|
+
* @param array{id: string, email: string|null, verified: bool, name: string|null} $profile
|
|
198
|
+
*/
|
|
199
|
+
private function link(string $provider, array $profile, mixed $user): bool
|
|
200
|
+
{
|
|
201
|
+
if (! Schema::hasTable(self::TABLE)) {
|
|
202
|
+
return true; // not migrated yet: matched by address every time until it is
|
|
203
|
+
}
|
|
204
|
+
$id = (string) $user->getAuthIdentifier();
|
|
205
|
+
DB::table(self::TABLE)->insertOrIgnore([
|
|
206
|
+
'id' => (string) Str::uuid(),
|
|
207
|
+
'user_id' => $id,
|
|
208
|
+
'provider' => $provider,
|
|
209
|
+
'provider_id' => $profile['id'],
|
|
210
|
+
'email' => $profile['email'],
|
|
211
|
+
'created_at' => now(),
|
|
212
|
+
'updated_at' => now(),
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
// Whichever row is there now is the answer.
|
|
216
|
+
return DB::table(self::TABLE)->where('provider', $provider)->where('provider_id', $profile['id'])->value('user_id') === $id;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The provider has just proved this address belongs to whoever is signing in. Nobody had
|
|
221
|
+
* proved it before: the account may have been made by someone else, in their name, with a
|
|
222
|
+
* password of their own. So that password stops working and every device is signed out.
|
|
223
|
+
* The owner signs in with the provider from here, or sets a password with "Forgot password".
|
|
224
|
+
*/
|
|
225
|
+
private function reclaim(mixed $user): void
|
|
226
|
+
{
|
|
227
|
+
Account::revokeTokens($user);
|
|
228
|
+
$user->forceFill(['password' => Hash::make(Str::random(48))])->save();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** @param array{id: string, email: string|null, verified: bool, name: string|null} $profile */
|
|
232
|
+
private function connect(string $provider, array $profile, string $userId, string $next): JsonResponse
|
|
233
|
+
{
|
|
234
|
+
$user = Account::model()::query()->find($userId);
|
|
235
|
+
if ($user === null) {
|
|
236
|
+
return $this->refuse('UNAUTHENTICATED', 'Sign in again, then connect it.', 401, ['next' => $next]);
|
|
237
|
+
}
|
|
238
|
+
$holder = $this->linked($provider, $profile['id']);
|
|
239
|
+
if (($holder !== null && (string) $holder->getAuthIdentifier() !== $userId) || ! $this->link($provider, $profile, $user)) {
|
|
240
|
+
return $this->refuse('ALREADY_LINKED', 'That '.Social::LABELS[$provider].' account is connected to another account here.', 409, ['next' => $next]);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return response()->json(['linked' => true, 'provider' => $provider, 'next' => $next]);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* A new account, where people may make their own: as `register` makes one, with the
|
|
248
|
+
* address already proved and a password nobody knows. Its owner signs in with the
|
|
249
|
+
* provider, or sets a password with "Forgot password".
|
|
250
|
+
*
|
|
251
|
+
* @param array{id: string, email: string|null, verified: bool, name: string|null} $profile
|
|
252
|
+
*/
|
|
253
|
+
private function register(array $profile): mixed
|
|
254
|
+
{
|
|
255
|
+
if (! config('nevela.auth.registration', false)) {
|
|
256
|
+
return $this->refuse('NO_ACCOUNT', "There's no account here for {$profile['email']}. Ask an administrator for one.", 403);
|
|
257
|
+
}
|
|
258
|
+
$standing = ClosedAccounts::standing((string) $profile['email']);
|
|
259
|
+
if ($standing === 'closed') {
|
|
260
|
+
return $this->refuse('ACCOUNT_CLOSED', 'An account with this email was closed. Ask an administrator to restore it.', 403);
|
|
261
|
+
}
|
|
262
|
+
if ($standing === 'blocked') {
|
|
263
|
+
return $this->refuse('EMAIL_BLOCKED', "This email can't be used for a new account. Ask an administrator.", 403);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
$model = Account::model();
|
|
267
|
+
$user = new $model;
|
|
268
|
+
$attributes = [
|
|
269
|
+
'name' => $profile['name'] ?: Str::before((string) $profile['email'], '@'),
|
|
270
|
+
'email' => $profile['email'],
|
|
271
|
+
'password' => Hash::make(Str::random(48)),
|
|
272
|
+
'email_verified_at' => now(),
|
|
273
|
+
];
|
|
274
|
+
$role = config('nevela.auth.default_role', 'USER');
|
|
275
|
+
if ($role !== null && Schema::hasColumn($user->getTable(), 'role')) {
|
|
276
|
+
$attributes['role'] = $role;
|
|
277
|
+
}
|
|
278
|
+
$user->forceFill($attributes)->save();
|
|
279
|
+
if (is_string($role) && $role !== '') {
|
|
280
|
+
Access::grant($user, $role);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return $user;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -22,7 +22,7 @@ use Nevela\Laravel\Support\Naming;
|
|
|
22
22
|
final class Nevela
|
|
23
23
|
{
|
|
24
24
|
/** Written by `pnpm release`; the same number as the repository's git tag. */
|
|
25
|
-
public const VERSION = '0.
|
|
25
|
+
public const VERSION = '0.10.0';
|
|
26
26
|
|
|
27
27
|
/** @var array<string, Descriptor> */
|
|
28
28
|
private static array $descriptors = [];
|
|
@@ -36,6 +36,7 @@ use Nevela\Laravel\Http\FormsController;
|
|
|
36
36
|
use Nevela\Laravel\Http\Auth\AccountController;
|
|
37
37
|
use Nevela\Laravel\Http\Auth\SecurityController;
|
|
38
38
|
use Nevela\Laravel\Http\Auth\SignInController;
|
|
39
|
+
use Nevela\Laravel\Http\Auth\SocialController;
|
|
39
40
|
use Nevela\Laravel\Http\PublicThrottle;
|
|
40
41
|
use Nevela\Laravel\Http\TokenController;
|
|
41
42
|
use Nevela\Laravel\Http\ActionController;
|
|
@@ -139,6 +140,9 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
139
140
|
Route::post('email-code/verify', [SignInController::class, 'code'])->name('email-code.verify');
|
|
140
141
|
Route::post('passkey/options', [SignInController::class, 'passkeyOptions'])->name('passkey.options');
|
|
141
142
|
Route::post('passkey', [SignInController::class, 'passkey'])->name('passkey.verify');
|
|
143
|
+
// Google and GitHub: called by the dashboard's server, on the way out and on the way back.
|
|
144
|
+
Route::post('oauth/{provider}/start', [SocialController::class, 'start'])->name('oauth.start');
|
|
145
|
+
Route::post('oauth/{provider}/callback', [SocialController::class, 'callback'])->name('oauth.callback');
|
|
142
146
|
Route::post('register', [AccountController::class, 'register'])->name('register');
|
|
143
147
|
Route::post('email/send', [AccountController::class, 'resendVerification'])->name('email.send');
|
|
144
148
|
Route::post('email/verify', [AccountController::class, 'verifyEmail'])->name('email.verify');
|
|
@@ -165,6 +169,9 @@ final class NevelaServiceProvider extends ServiceProvider
|
|
|
165
169
|
Route::post('two-factor/backup-codes', [SecurityController::class, 'regenerateBackupCodes'])->name('two-factor.backup-codes');
|
|
166
170
|
});
|
|
167
171
|
|
|
172
|
+
Route::get('social', [SocialController::class, 'accounts'])->name('social');
|
|
173
|
+
Route::delete('social/{id}', [SocialController::class, 'disconnect'])->name('social.disconnect');
|
|
174
|
+
|
|
168
175
|
Route::get('passkeys', [SecurityController::class, 'passkeys'])->name('passkeys');
|
|
169
176
|
Route::post('passkeys/options', [SecurityController::class, 'passkeyOptions'])->name('passkeys.options');
|
|
170
177
|
Route::post('passkeys', [SecurityController::class, 'addPasskey'])->name('passkeys.add');
|
|
@@ -86,6 +86,69 @@ async function callLaravel(request: NextRequest, method: string, path: string, b
|
|
|
86
86
|
return { status: response.status, body: parsed };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Which browser started a sign-in with Google or GitHub. The provider's answer is taken
|
|
91
|
+
* only in the browser that asked for it, so nobody can sign someone else in to their own
|
|
92
|
+
* account by sending them a link.
|
|
93
|
+
*/
|
|
94
|
+
const OAUTH_COOKIE = "nevela_oauth";
|
|
95
|
+
const OAUTH_PATH = "/api/auth/oauth";
|
|
96
|
+
|
|
97
|
+
const redirectTo = (request: NextRequest, path: string) => Response.redirect(new URL(path, request.url), 303);
|
|
98
|
+
|
|
99
|
+
/** A path with something added to its query: "/dashboard/account/security" + social=linked. */
|
|
100
|
+
function withQuery(path: string, name: string, value: string): string {
|
|
101
|
+
return `${path}${path.includes("?") ? "&" : "?"}${name}=${encodeURIComponent(value)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The button: ask Laravel where the provider's page is, and send the browser there. */
|
|
105
|
+
async function oauthStart(request: NextRequest, provider: string): Promise<Response> {
|
|
106
|
+
const params = request.nextUrl.searchParams;
|
|
107
|
+
const linking = params.get("link") === "1";
|
|
108
|
+
const next = safeRedirectPath(params.get("next"), linking ? "/dashboard/account/security" : "/dashboard");
|
|
109
|
+
const result = await callLaravel(request, "POST", `oauth/${provider}/start`, { next, link: linking });
|
|
110
|
+
const { url, state } = result.body;
|
|
111
|
+
if (result.status !== 200 || typeof url !== "string" || typeof state !== "string") {
|
|
112
|
+
const code = typeof result.body.code === "string" ? result.body.code : "OAUTH_FAILED";
|
|
113
|
+
return redirectTo(request, linking ? withQuery(next, "social", code) : `/sign-in?error=oauth&reason=${code}`);
|
|
114
|
+
}
|
|
115
|
+
(await cookies()).set(OAUTH_COOKIE, state, { ...cookieOptions(60 * 10), path: OAUTH_PATH });
|
|
116
|
+
return Response.redirect(url, 303);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Back from the provider: hand Laravel the code, and sign in as any other way does. */
|
|
120
|
+
async function oauthCallback(request: NextRequest, provider: string): Promise<Response> {
|
|
121
|
+
const params = request.nextUrl.searchParams;
|
|
122
|
+
const jar = await cookies();
|
|
123
|
+
const state = params.get("state") ?? "";
|
|
124
|
+
const started = jar.get(OAUTH_COOKIE)?.value;
|
|
125
|
+
jar.set(OAUTH_COOKIE, "", { ...cookieOptions(0), path: OAUTH_PATH });
|
|
126
|
+
if (!started || started !== state) return redirectTo(request, "/sign-in?error=oauth&reason=OAUTH_STATE");
|
|
127
|
+
|
|
128
|
+
const result = await callLaravel(request, "POST", `oauth/${provider}/callback`, {
|
|
129
|
+
state,
|
|
130
|
+
code: params.get("code") ?? "",
|
|
131
|
+
...(params.get("error") ? { error: params.get("error") } : {}),
|
|
132
|
+
});
|
|
133
|
+
const next = safeRedirectPath(result.body.next);
|
|
134
|
+
// Connected to the account that asked, from the Account page.
|
|
135
|
+
if (result.body.linked === true) return redirectTo(request, withQuery(next, "social", "linked"));
|
|
136
|
+
|
|
137
|
+
const waiting = result.status === 200 ? secondStep(result.body) : null;
|
|
138
|
+
if (waiting) {
|
|
139
|
+
jar.set(PENDING_COOKIE, encodePending(waiting), cookieOptions(PENDING_MAX_AGE));
|
|
140
|
+
return redirectTo(request, `/two-factor?next=${encodeURIComponent(next)}`);
|
|
141
|
+
}
|
|
142
|
+
const token = result.body.token;
|
|
143
|
+
if (result.status >= 400 || typeof token !== "string") {
|
|
144
|
+
const code = typeof result.body.code === "string" ? result.body.code : "OAUTH_FAILED";
|
|
145
|
+
return redirectTo(request, next.startsWith("/dashboard/account") ? withQuery(next, "social", code) : `/sign-in?error=oauth&reason=${code}&next=${encodeURIComponent(next)}`);
|
|
146
|
+
}
|
|
147
|
+
jar.set(TOKEN_COOKIE, token, cookieOptions(TOKEN_MAX_AGE));
|
|
148
|
+
jar.set(PENDING_COOKIE, "", cookieOptions(0));
|
|
149
|
+
return redirectTo(request, next);
|
|
150
|
+
}
|
|
151
|
+
|
|
89
152
|
/** A sign-in that needs its second step: Laravel answered with a challenge instead of a token. */
|
|
90
153
|
function secondStep(body: Body): { challenge: string; methods: SecondStepMethod[] } | null {
|
|
91
154
|
if (body.twoFactor !== true || typeof body.challenge !== "string") return null;
|
|
@@ -136,6 +199,10 @@ async function handle(request: NextRequest, context: Context, method: string): P
|
|
|
136
199
|
return Response.redirect(destination.origin === new URL(request.url).origin ? destination : new URL("/dashboard", request.url), 303);
|
|
137
200
|
}
|
|
138
201
|
|
|
202
|
+
// Google and GitHub: the button opens the first, and the provider sends the browser back to the second.
|
|
203
|
+
const oauth = /^oauth\/([a-z]+)(\/callback)?$/.exec(action);
|
|
204
|
+
if (method === "GET" && oauth) return oauth[2] ? oauthCallback(request, oauth[1]) : oauthStart(request, oauth[1]);
|
|
205
|
+
|
|
139
206
|
if (method !== "GET" && !fromThisSite(request)) {
|
|
140
207
|
return Response.json({ error: "That request didn't come from this site.", code: "CROSS_SITE" }, { status: 403 });
|
|
141
208
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { AccountNav } from "@/components/account/account-nav";
|
|
2
2
|
import { PageHeader } from "@/components/dashboard/page-header";
|
|
3
|
-
import { authConfig,
|
|
3
|
+
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
4
|
+
import { enabledSocialProviders } from "@/lib/social";
|
|
4
5
|
import { requireSession } from "@/lib/session";
|
|
5
6
|
|
|
6
7
|
/** The frame the account's pages share: one header, one set of links. */
|
|
7
8
|
export default async function AccountLayout({ children }: { children: React.ReactNode }) {
|
|
8
9
|
await requireSession("/dashboard/account");
|
|
9
|
-
const security = twoFactorAvailable || authConfig.passkeys || enabledSocialProviders().length > 0;
|
|
10
|
+
const security = twoFactorAvailable || authConfig.passkeys || (await enabledSocialProviders()).length > 0;
|
|
10
11
|
|
|
11
12
|
return (
|
|
12
13
|
<>
|
|
@@ -1,19 +1,31 @@
|
|
|
1
1
|
import { ConnectedAccounts, Passkeys, TwoFactor } from "@/components/account/sections";
|
|
2
|
-
import { authConfig,
|
|
2
|
+
import { authConfig, twoFactorAvailable } from "@/lib/auth-config";
|
|
3
3
|
import { requireSession } from "@/lib/session";
|
|
4
|
+
import { enabledSocialProviders } from "@/lib/social";
|
|
5
|
+
|
|
6
|
+
/** What became of connecting a provider, by what Laravel answered: the provider sends the browser back here. */
|
|
7
|
+
const CONNECTING: Record<string, string> = {
|
|
8
|
+
ALREADY_LINKED: "That account is connected to another account here.",
|
|
9
|
+
NO_EMAIL: "That account didn't share an email address.",
|
|
10
|
+
OAUTH_DENIED: "Connecting it was cancelled.",
|
|
11
|
+
OAUTH_STATE: "That took too long. Try again.",
|
|
12
|
+
NOT_ENABLED: "Signing in with that provider isn't switched on.",
|
|
13
|
+
};
|
|
4
14
|
|
|
5
15
|
export const metadata = { title: "Security" };
|
|
6
16
|
|
|
7
17
|
/** Two-factor, passkeys and the accounts you can sign in with. */
|
|
8
|
-
export default async function SecurityPage() {
|
|
18
|
+
export default async function SecurityPage({ searchParams }: { searchParams: Promise<{ social?: string }> }) {
|
|
9
19
|
const { user } = await requireSession("/dashboard/account/security");
|
|
10
|
-
const providers = enabledSocialProviders();
|
|
20
|
+
const providers = await enabledSocialProviders();
|
|
21
|
+
const { social } = await searchParams;
|
|
22
|
+
const connecting = social === undefined || social === "linked" ? null : (CONNECTING[social] ?? "Connecting it didn't finish. Try again.");
|
|
11
23
|
|
|
12
24
|
return (
|
|
13
25
|
<>
|
|
14
26
|
{twoFactorAvailable && <TwoFactor enabled={user.twoFactorEnabled} hasPassword methods={authConfig.twoFactor} />}
|
|
15
27
|
{authConfig.passkeys && <Passkeys />}
|
|
16
|
-
{providers.length > 0 && <ConnectedAccounts providers={providers} />}
|
|
28
|
+
{providers.length > 0 && <ConnectedAccounts providers={providers} initialError={connecting} />}
|
|
17
29
|
</>
|
|
18
30
|
);
|
|
19
31
|
}
|
|
@@ -2,7 +2,8 @@ import Link from "next/link";
|
|
|
2
2
|
import { redirect } from "next/navigation";
|
|
3
3
|
import { AuthShell } from "@/components/auth/auth-shell";
|
|
4
4
|
import { SignInFlow } from "@/components/auth/sign-in-flow";
|
|
5
|
-
import { authConfig
|
|
5
|
+
import { authConfig } from "@/lib/auth-config";
|
|
6
|
+
import { enabledSocialProviders } from "@/lib/social";
|
|
6
7
|
import { getSession, safeRedirectPath } from "@/lib/session";
|
|
7
8
|
import { site } from "@/lib/site";
|
|
8
9
|
import { theme } from "@/lib/theme";
|
|
@@ -14,7 +15,20 @@ const ERRORS: Record<string, string> = {
|
|
|
14
15
|
link: "That sign-in link has expired or was already used. Ask for a new one.",
|
|
15
16
|
};
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
/** Why a sign-in with Google or GitHub didn't finish, as Laravel names it. */
|
|
19
|
+
const OAUTH_REASONS: Record<string, string> = {
|
|
20
|
+
NO_ACCOUNT: "There's no account here for that address. Ask an administrator for one.",
|
|
21
|
+
NO_EMAIL: "That account didn't share an email address, so it can't be matched to an account here.",
|
|
22
|
+
EMAIL_NOT_VERIFIED: "Verify your email address with the provider first, then try again.",
|
|
23
|
+
ACCOUNT_CLOSED: "This account was closed. Ask an administrator to restore it.",
|
|
24
|
+
ACCOUNT_DISABLED: "This account has been switched off. Ask an administrator.",
|
|
25
|
+
EMAIL_BLOCKED: "This email can't be used for a new account. Ask an administrator.",
|
|
26
|
+
OAUTH_DENIED: "Sign-in was cancelled.",
|
|
27
|
+
OAUTH_STATE: "That sign-in took too long or was started in another browser. Try again.",
|
|
28
|
+
NOT_ENABLED: "Signing in with that provider isn't switched on.",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export default async function SignInPage({ searchParams }: { searchParams: Promise<{ next?: string; error?: string; reason?: string; reset?: string; closed?: string }> }) {
|
|
18
32
|
const params = await searchParams;
|
|
19
33
|
const next = safeRedirectPath(params.next);
|
|
20
34
|
if (await getSession()) redirect(next);
|
|
@@ -38,12 +52,14 @@ export default async function SignInPage({ searchParams }: { searchParams: Promi
|
|
|
38
52
|
}
|
|
39
53
|
>
|
|
40
54
|
<SignInFlow
|
|
41
|
-
providers={enabledSocialProviders()}
|
|
55
|
+
providers={await enabledSocialProviders()}
|
|
42
56
|
socialPlacement={theme.social}
|
|
43
57
|
socialStyle={theme.socialStyle}
|
|
44
58
|
methods={{ magicLink: authConfig.magicLink, emailOtp: authConfig.emailOtp, passkeys: authConfig.passkeys }}
|
|
45
59
|
next={next}
|
|
46
|
-
initialError={
|
|
60
|
+
initialError={
|
|
61
|
+
params.error ? ((params.error === "oauth" && params.reason ? OAUTH_REASONS[params.reason] : undefined) ?? ERRORS[params.error] ?? "Sign-in didn't finish. Try again.") : null
|
|
62
|
+
}
|
|
47
63
|
initialNotice={
|
|
48
64
|
params.reset
|
|
49
65
|
? "Your password is changed. Sign in with the new one."
|
|
@@ -2,7 +2,8 @@ import Link from "next/link";
|
|
|
2
2
|
import { redirect } from "next/navigation";
|
|
3
3
|
import { AuthShell } from "@/components/auth/auth-shell";
|
|
4
4
|
import { SignUpForm } from "@/components/auth/sign-up-form";
|
|
5
|
-
import { authConfig
|
|
5
|
+
import { authConfig } from "@/lib/auth-config";
|
|
6
|
+
import { enabledSocialProviders } from "@/lib/social";
|
|
6
7
|
import { getSession, safeRedirectPath } from "@/lib/session";
|
|
7
8
|
import { site } from "@/lib/site";
|
|
8
9
|
import { theme } from "@/lib/theme";
|
|
@@ -32,7 +33,7 @@ export default async function SignUpPage({ searchParams }: { searchParams: Promi
|
|
|
32
33
|
}
|
|
33
34
|
>
|
|
34
35
|
<SignUpForm
|
|
35
|
-
providers={enabledSocialProviders()}
|
|
36
|
+
providers={await enabledSocialProviders()}
|
|
36
37
|
socialPlacement={theme.social}
|
|
37
38
|
socialStyle={theme.socialStyle}
|
|
38
39
|
next={next}
|
|
@@ -524,9 +524,9 @@ export function Passkeys() {
|
|
|
524
524
|
);
|
|
525
525
|
}
|
|
526
526
|
|
|
527
|
-
export function ConnectedAccounts({ providers }: { providers: SocialProvider[] }) {
|
|
527
|
+
export function ConnectedAccounts({ providers, initialError = null }: { providers: SocialProvider[]; initialError?: string | null }) {
|
|
528
528
|
const [linked, setLinked] = useState<{ providerId: string; accountId: string }[] | null>(null);
|
|
529
|
-
const [error, setError] = useState<string | null>(
|
|
529
|
+
const [error, setError] = useState<string | null>(initialError);
|
|
530
530
|
|
|
531
531
|
const load = useCallback(async () => {
|
|
532
532
|
const { data } = await authClient.listAccounts();
|
|
@@ -537,7 +537,7 @@ export function ConnectedAccounts({ providers }: { providers: SocialProvider[] }
|
|
|
537
537
|
}, [load]);
|
|
538
538
|
|
|
539
539
|
const connect = async (provider: SocialProvider) => {
|
|
540
|
-
const { error } = await authClient.linkSocial({ provider, callbackURL: "/dashboard/account" });
|
|
540
|
+
const { error } = await authClient.linkSocial({ provider, callbackURL: "/dashboard/account/security" });
|
|
541
541
|
if (error) setError(authErrorMessage(error));
|
|
542
542
|
};
|
|
543
543
|
const disconnect = async (provider: SocialProvider) => {
|
|
@@ -179,7 +179,8 @@ export const authClient = {
|
|
|
179
179
|
magicLink: ({ email, callbackURL }: { email: string; callbackURL?: string; errorCallbackURL?: string }) => post("magic-link", { email, next: callbackURL }),
|
|
180
180
|
emailOtp: ({ email, otp }: { email: string; otp: string }) => orSecondStep(post<{ twoFactorRedirect?: boolean }>("email-code/verify", { email, code: otp })),
|
|
181
181
|
passkey: signInWithPasskey,
|
|
182
|
-
|
|
182
|
+
/** Off to the provider's page: the browser leaves, and comes back signed in. */
|
|
183
|
+
social: ({ provider, callbackURL }: { provider: string; callbackURL?: string; errorCallbackURL?: string }) => toProvider(provider, callbackURL),
|
|
183
184
|
},
|
|
184
185
|
signUp: {
|
|
185
186
|
email: ({ name, email, password }: { name: string; email: string; password: string; callbackURL?: string }) =>
|
|
@@ -247,12 +248,28 @@ export const authClient = {
|
|
|
247
248
|
deletePasskey: ({ id }: { id: string }) => call("DELETE", `passkeys/${encodeURIComponent(id)}`),
|
|
248
249
|
},
|
|
249
250
|
|
|
250
|
-
|
|
251
|
-
listAccounts: async (): Promise<AuthResult<{ id: string; providerId: string; accountId: string }[]>> =>
|
|
252
|
-
|
|
253
|
-
|
|
251
|
+
/** The providers connected to this account. */
|
|
252
|
+
listAccounts: async (): Promise<AuthResult<{ id: string; providerId: string; accountId: string }[]>> => {
|
|
253
|
+
const result = await call<{ data?: { id: string; provider: string }[] }>("GET", "social");
|
|
254
|
+
if (result.error) return { data: null, error: result.error };
|
|
255
|
+
return { data: (result.data?.data ?? []).map((row) => ({ id: row.id, providerId: row.provider, accountId: row.id })), error: null };
|
|
256
|
+
},
|
|
257
|
+
/** Off to the provider's page, to come back connected to this account. */
|
|
258
|
+
linkSocial: ({ provider, callbackURL }: { provider: string; callbackURL?: string }) => toProvider(provider, callbackURL ?? "/dashboard/account/security", true),
|
|
259
|
+
unlinkAccount: ({ accountId }: { providerId?: string; accountId?: string }) => call("DELETE", `social/${encodeURIComponent(accountId ?? "")}`),
|
|
254
260
|
};
|
|
255
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Leave for Google or GitHub. The browser goes to this app's own route, which asks Laravel
|
|
264
|
+
* where the provider's page is: nothing about the sign-in passes through the page's script.
|
|
265
|
+
*/
|
|
266
|
+
async function toProvider(provider: string, next?: string, link = false): Promise<AuthResult<never>> {
|
|
267
|
+
const query = new URLSearchParams({ next: next ?? "/dashboard", ...(link ? { link: "1" } : {}) });
|
|
268
|
+
window.location.assign(`/api/auth/oauth/${encodeURIComponent(provider)}?${query}`);
|
|
269
|
+
// The page is going; nothing more happens here.
|
|
270
|
+
return new Promise(() => {});
|
|
271
|
+
}
|
|
272
|
+
|
|
256
273
|
/**
|
|
257
274
|
* Upload a profile picture. It is the request body, so the browser can report progress,
|
|
258
275
|
* and Laravel optimises it like any image field: a 400×400 square with a small thumbnail.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export type SocialProvider = "google" | "github" | "apple" | "microsoft";
|
|
11
11
|
|
|
12
|
-
// nevela:generated:start hash=
|
|
12
|
+
// nevela:generated:start hash=f61dde748946
|
|
13
13
|
export const authConfig = {
|
|
14
14
|
/** People can create their own account at /sign-up. */
|
|
15
15
|
registration: false,
|
|
@@ -31,7 +31,10 @@ export const authConfig = {
|
|
|
31
31
|
checkBreachedPasswords: true,
|
|
32
32
|
/** People can close their own account, from the Account page. */
|
|
33
33
|
closeAccount: true,
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Always empty here. Which of Google and GitHub show is asked of Laravel on each
|
|
36
|
+
* page, so SOCIAL_AUTH_ENABLED needs no generate or rebuild: see lib/social.ts.
|
|
37
|
+
*/
|
|
35
38
|
social: [] as SocialProvider[],
|
|
36
39
|
};
|
|
37
40
|
// nevela:generated:end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { SocialProvider } from "@/lib/auth-config";
|
|
2
|
+
import { laravel } from "@/lib/laravel";
|
|
3
|
+
|
|
4
|
+
const KNOWN: SocialProvider[] = ["google", "github", "apple", "microsoft"];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The providers people can sign in with: switched on in the Laravel app
|
|
8
|
+
* (SOCIAL_AUTH_ENABLED) and given their keys there.
|
|
9
|
+
*
|
|
10
|
+
* Asked of Laravel each time a page needs it, so turning the switch on or off takes effect
|
|
11
|
+
* with no `nevela generate` and no rebuild. None when Laravel can't be reached: a button
|
|
12
|
+
* that can't work isn't shown. For a server component; the browser never asks.
|
|
13
|
+
*/
|
|
14
|
+
export async function enabledSocialProviders(): Promise<SocialProvider[]> {
|
|
15
|
+
try {
|
|
16
|
+
const { ok, body } = await laravel("auth/config", { token: null });
|
|
17
|
+
const listed = ok ? (body as { social?: unknown }).social : [];
|
|
18
|
+
return Array.isArray(listed) ? KNOWN.filter((provider) => listed.includes(provider)) : [];
|
|
19
|
+
} catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
}
|