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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,7 @@
31
31
  "illuminate/database": "^11.17|^12.0|^13.0",
32
32
  "illuminate/http": "^11.0|^12.0|^13.0",
33
33
  "illuminate/console": "^11.0|^12.0|^13.0",
34
+ "laravel/socialite": "^5.31",
34
35
  "lbuchs/webauthn": "^2.2"
35
36
  },
36
37
  "require-dev": {
@@ -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
- /** Signing in with Google, GitHub and the like isn't part of Nevela yet. */
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
- /** The social providers that are switched on and configured. None yet. */
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
- private function refuse(string $code, string $message, int $status = 400): JsonResponse
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.9.2';
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, enabledSocialProviders, twoFactorAvailable } from "@/lib/auth-config";
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, enabledSocialProviders, twoFactorAvailable } from "@/lib/auth-config";
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, enabledSocialProviders } from "@/lib/auth-config";
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
- export default async function SignInPage({ searchParams }: { searchParams: Promise<{ next?: string; error?: string; reset?: string; closed?: string }> }) {
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={params.error ? (ERRORS[params.error] ?? "Sign-in didn't finish. Try again.") : null}
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, enabledSocialProviders } from "@/lib/auth-config";
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>(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
- social: (_options: { provider: string; callbackURL?: string; errorCallbackURL?: string }) => unsupported("Signing in with that provider"),
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
- // Signing in with Google, GitHub and the like isn't part of Nevela yet.
251
- listAccounts: async (): Promise<AuthResult<{ id: string; providerId: string; accountId: string }[]>> => ({ data: [], error: null }),
252
- linkSocial: (_options: { provider: string; callbackURL?: string }) => unsupported("Connecting that provider"),
253
- unlinkAccount: (_options: { providerId?: string; accountId?: string }) => unsupported("Disconnecting that provider"),
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=68b0e3641ced
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
- /** Signing in with Google, GitHub and the like isn't part of Nevela yet. */
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
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nevela-web",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {