2026-05-19 00:30:20 +02:00
|
|
|
'use client';
|
|
|
|
|
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
import { CountryPicker } from '@/components/country-picker';
|
2026-05-21 00:26:44 +02:00
|
|
|
import { Input, Label } from '@/components/input';
|
2026-05-19 00:30:20 +02:00
|
|
|
import { Logo } from '@/components/logo';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
2026-05-21 00:26:44 +02:00
|
|
|
import { apiFetch, apiUrl } from '@/lib/api';
|
|
|
|
|
import Link from 'next/link';
|
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
const ERROR_COPY: Record<string, string> = {
|
|
|
|
|
google_failed: 'Google sign-in could not be completed. Please try again.',
|
|
|
|
|
google_state: 'Google sign-in expired or was interrupted. Please try again.',
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
github_failed: 'GitHub sign-in could not be completed. Please try again.',
|
|
|
|
|
github_state: 'GitHub sign-in expired or was interrupted. Please try again.',
|
2026-05-21 23:41:19 +02:00
|
|
|
invalid_phone: 'That phone number does not look right. Check the country and number.',
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
rate_limited: 'Too many requests. Wait a few minutes and try again.',
|
|
|
|
|
sms_request_failed: 'Could not send the SMS. Check the number and try again.',
|
|
|
|
|
invalid_or_expired_code: 'That code has expired. Request a new one.',
|
|
|
|
|
invalid_code: 'Wrong code. Check the SMS and try again.',
|
|
|
|
|
too_many_attempts: 'Too many wrong attempts. Request a new code.',
|
|
|
|
|
sms_verify_failed: 'Could not verify the code. Try again.',
|
2026-05-21 00:26:44 +02:00
|
|
|
};
|
2026-05-19 00:30:20 +02:00
|
|
|
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
// Country dial codes for the phone-login picker. ~150 entries — every country
|
|
|
|
|
// with a non-trivial diaspora. Sorted alphabetically by name. Switzerland is
|
|
|
|
|
// the default (Swiss-built product, Swiss Twilio sender number).
|
2026-05-21 23:41:19 +02:00
|
|
|
const COUNTRIES: { code: string; name: string; dial: string }[] = [
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'AL', name: 'Albania', dial: '+355' },
|
|
|
|
|
{ code: 'DZ', name: 'Algeria', dial: '+213' },
|
|
|
|
|
{ code: 'AD', name: 'Andorra', dial: '+376' },
|
|
|
|
|
{ code: 'AO', name: 'Angola', dial: '+244' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'AR', name: 'Argentina', dial: '+54' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'AM', name: 'Armenia', dial: '+374' },
|
|
|
|
|
{ code: 'AW', name: 'Aruba', dial: '+297' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'AU', name: 'Australia', dial: '+61' },
|
|
|
|
|
{ code: 'AT', name: 'Austria', dial: '+43' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'AZ', name: 'Azerbaijan', dial: '+994' },
|
|
|
|
|
{ code: 'BS', name: 'Bahamas', dial: '+1' },
|
|
|
|
|
{ code: 'BH', name: 'Bahrain', dial: '+973' },
|
|
|
|
|
{ code: 'BD', name: 'Bangladesh', dial: '+880' },
|
|
|
|
|
{ code: 'BB', name: 'Barbados', dial: '+1' },
|
|
|
|
|
{ code: 'BY', name: 'Belarus', dial: '+375' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'BE', name: 'Belgium', dial: '+32' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'BZ', name: 'Belize', dial: '+501' },
|
|
|
|
|
{ code: 'BJ', name: 'Benin', dial: '+229' },
|
|
|
|
|
{ code: 'BM', name: 'Bermuda', dial: '+1' },
|
|
|
|
|
{ code: 'BT', name: 'Bhutan', dial: '+975' },
|
|
|
|
|
{ code: 'BO', name: 'Bolivia', dial: '+591' },
|
|
|
|
|
{ code: 'BA', name: 'Bosnia & Herzegovina', dial: '+387' },
|
|
|
|
|
{ code: 'BW', name: 'Botswana', dial: '+267' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'BR', name: 'Brazil', dial: '+55' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'BN', name: 'Brunei', dial: '+673' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'BG', name: 'Bulgaria', dial: '+359' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'BF', name: 'Burkina Faso', dial: '+226' },
|
|
|
|
|
{ code: 'KH', name: 'Cambodia', dial: '+855' },
|
|
|
|
|
{ code: 'CM', name: 'Cameroon', dial: '+237' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'CA', name: 'Canada', dial: '+1' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'CV', name: 'Cape Verde', dial: '+238' },
|
|
|
|
|
{ code: 'KY', name: 'Cayman Islands', dial: '+1' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'CL', name: 'Chile', dial: '+56' },
|
|
|
|
|
{ code: 'CN', name: 'China', dial: '+86' },
|
|
|
|
|
{ code: 'CO', name: 'Colombia', dial: '+57' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'CR', name: 'Costa Rica', dial: '+506' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'HR', name: 'Croatia', dial: '+385' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'CY', name: 'Cyprus', dial: '+357' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'CZ', name: 'Czechia', dial: '+420' },
|
|
|
|
|
{ code: 'DK', name: 'Denmark', dial: '+45' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'DO', name: 'Dominican Republic', dial: '+1' },
|
|
|
|
|
{ code: 'EC', name: 'Ecuador', dial: '+593' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'EG', name: 'Egypt', dial: '+20' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'SV', name: 'El Salvador', dial: '+503' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'EE', name: 'Estonia', dial: '+372' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'ET', name: 'Ethiopia', dial: '+251' },
|
|
|
|
|
{ code: 'FJ', name: 'Fiji', dial: '+679' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'FI', name: 'Finland', dial: '+358' },
|
|
|
|
|
{ code: 'FR', name: 'France', dial: '+33' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'GE', name: 'Georgia', dial: '+995' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'DE', name: 'Germany', dial: '+49' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'GH', name: 'Ghana', dial: '+233' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'GR', name: 'Greece', dial: '+30' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'GT', name: 'Guatemala', dial: '+502' },
|
|
|
|
|
{ code: 'HN', name: 'Honduras', dial: '+504' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'HK', name: 'Hong Kong', dial: '+852' },
|
|
|
|
|
{ code: 'HU', name: 'Hungary', dial: '+36' },
|
|
|
|
|
{ code: 'IS', name: 'Iceland', dial: '+354' },
|
|
|
|
|
{ code: 'IN', name: 'India', dial: '+91' },
|
|
|
|
|
{ code: 'ID', name: 'Indonesia', dial: '+62' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'IR', name: 'Iran', dial: '+98' },
|
|
|
|
|
{ code: 'IQ', name: 'Iraq', dial: '+964' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'IE', name: 'Ireland', dial: '+353' },
|
|
|
|
|
{ code: 'IL', name: 'Israel', dial: '+972' },
|
|
|
|
|
{ code: 'IT', name: 'Italy', dial: '+39' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'CI', name: 'Ivory Coast', dial: '+225' },
|
|
|
|
|
{ code: 'JM', name: 'Jamaica', dial: '+1' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'JP', name: 'Japan', dial: '+81' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'JO', name: 'Jordan', dial: '+962' },
|
|
|
|
|
{ code: 'KZ', name: 'Kazakhstan', dial: '+7' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'KE', name: 'Kenya', dial: '+254' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'XK', name: 'Kosovo', dial: '+383' },
|
|
|
|
|
{ code: 'KW', name: 'Kuwait', dial: '+965' },
|
|
|
|
|
{ code: 'KG', name: 'Kyrgyzstan', dial: '+996' },
|
|
|
|
|
{ code: 'LA', name: 'Laos', dial: '+856' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'LV', name: 'Latvia', dial: '+371' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'LB', name: 'Lebanon', dial: '+961' },
|
|
|
|
|
{ code: 'LY', name: 'Libya', dial: '+218' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'LI', name: 'Liechtenstein', dial: '+423' },
|
|
|
|
|
{ code: 'LT', name: 'Lithuania', dial: '+370' },
|
|
|
|
|
{ code: 'LU', name: 'Luxembourg', dial: '+352' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'MO', name: 'Macau', dial: '+853' },
|
|
|
|
|
{ code: 'MK', name: 'North Macedonia', dial: '+389' },
|
|
|
|
|
{ code: 'MG', name: 'Madagascar', dial: '+261' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'MY', name: 'Malaysia', dial: '+60' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'MV', name: 'Maldives', dial: '+960' },
|
|
|
|
|
{ code: 'MT', name: 'Malta', dial: '+356' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'MX', name: 'Mexico', dial: '+52' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'MD', name: 'Moldova', dial: '+373' },
|
|
|
|
|
{ code: 'MC', name: 'Monaco', dial: '+377' },
|
|
|
|
|
{ code: 'MN', name: 'Mongolia', dial: '+976' },
|
|
|
|
|
{ code: 'ME', name: 'Montenegro', dial: '+382' },
|
|
|
|
|
{ code: 'MA', name: 'Morocco', dial: '+212' },
|
|
|
|
|
{ code: 'MZ', name: 'Mozambique', dial: '+258' },
|
|
|
|
|
{ code: 'MM', name: 'Myanmar', dial: '+95' },
|
|
|
|
|
{ code: 'NA', name: 'Namibia', dial: '+264' },
|
|
|
|
|
{ code: 'NP', name: 'Nepal', dial: '+977' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'NL', name: 'Netherlands', dial: '+31' },
|
|
|
|
|
{ code: 'NZ', name: 'New Zealand', dial: '+64' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'NI', name: 'Nicaragua', dial: '+505' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'NG', name: 'Nigeria', dial: '+234' },
|
|
|
|
|
{ code: 'NO', name: 'Norway', dial: '+47' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'OM', name: 'Oman', dial: '+968' },
|
|
|
|
|
{ code: 'PK', name: 'Pakistan', dial: '+92' },
|
|
|
|
|
{ code: 'PS', name: 'Palestine', dial: '+970' },
|
|
|
|
|
{ code: 'PA', name: 'Panama', dial: '+507' },
|
|
|
|
|
{ code: 'PG', name: 'Papua New Guinea', dial: '+675' },
|
|
|
|
|
{ code: 'PY', name: 'Paraguay', dial: '+595' },
|
|
|
|
|
{ code: 'PE', name: 'Peru', dial: '+51' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'PH', name: 'Philippines', dial: '+63' },
|
|
|
|
|
{ code: 'PL', name: 'Poland', dial: '+48' },
|
|
|
|
|
{ code: 'PT', name: 'Portugal', dial: '+351' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'PR', name: 'Puerto Rico', dial: '+1' },
|
|
|
|
|
{ code: 'QA', name: 'Qatar', dial: '+974' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'RO', name: 'Romania', dial: '+40' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'RU', name: 'Russia', dial: '+7' },
|
|
|
|
|
{ code: 'RW', name: 'Rwanda', dial: '+250' },
|
|
|
|
|
{ code: 'SM', name: 'San Marino', dial: '+378' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'SA', name: 'Saudi Arabia', dial: '+966' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'SN', name: 'Senegal', dial: '+221' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'RS', name: 'Serbia', dial: '+381' },
|
|
|
|
|
{ code: 'SG', name: 'Singapore', dial: '+65' },
|
|
|
|
|
{ code: 'SK', name: 'Slovakia', dial: '+421' },
|
|
|
|
|
{ code: 'SI', name: 'Slovenia', dial: '+386' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'SO', name: 'Somalia', dial: '+252' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'ZA', name: 'South Africa', dial: '+27' },
|
|
|
|
|
{ code: 'KR', name: 'South Korea', dial: '+82' },
|
|
|
|
|
{ code: 'ES', name: 'Spain', dial: '+34' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'LK', name: 'Sri Lanka', dial: '+94' },
|
|
|
|
|
{ code: 'SD', name: 'Sudan', dial: '+249' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'SE', name: 'Sweden', dial: '+46' },
|
|
|
|
|
{ code: 'CH', name: 'Switzerland', dial: '+41' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'SY', name: 'Syria', dial: '+963' },
|
|
|
|
|
{ code: 'TW', name: 'Taiwan', dial: '+886' },
|
|
|
|
|
{ code: 'TJ', name: 'Tajikistan', dial: '+992' },
|
|
|
|
|
{ code: 'TZ', name: 'Tanzania', dial: '+255' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'TH', name: 'Thailand', dial: '+66' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'TT', name: 'Trinidad & Tobago', dial: '+1' },
|
|
|
|
|
{ code: 'TN', name: 'Tunisia', dial: '+216' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'TR', name: 'Turkey', dial: '+90' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'TM', name: 'Turkmenistan', dial: '+993' },
|
|
|
|
|
{ code: 'UG', name: 'Uganda', dial: '+256' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'UA', name: 'Ukraine', dial: '+380' },
|
|
|
|
|
{ code: 'AE', name: 'United Arab Emirates', dial: '+971' },
|
|
|
|
|
{ code: 'GB', name: 'United Kingdom', dial: '+44' },
|
|
|
|
|
{ code: 'US', name: 'United States', dial: '+1' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'UY', name: 'Uruguay', dial: '+598' },
|
|
|
|
|
{ code: 'UZ', name: 'Uzbekistan', dial: '+998' },
|
|
|
|
|
{ code: 'VE', name: 'Venezuela', dial: '+58' },
|
2026-05-21 23:41:19 +02:00
|
|
|
{ code: 'VN', name: 'Vietnam', dial: '+84' },
|
feat(login): custom CountryPicker — opens downward, searchable, ~150 countries
Native <select> defers dropdown direction to the browser, which on mobile
routinely opens upward and hides countries behind the keyboard. Replaced
with a custom combobox that always opens DOWNWARD (absolute positioned
below the trigger) with a search input at top — at 150 countries a
scrollable list is unusable without search anyway.
COUNTRIES list expanded from 60 → 152 entries: every country with a
meaningful diaspora, including Russia, Pakistan, Bangladesh, Sri Lanka,
Cyprus, Malta, Albania, Bosnia, Kosovo, North Macedonia, Iran, Iraq,
Lebanon, Jordan, Kazakhstan, Morocco, Algeria, Tunisia, Ethiopia,
Tanzania, Uganda, Senegal, Ghana, Madagascar, Cameroon, Sri Lanka,
Belarus, Georgia, Armenia, Azerbaijan and the rest. Serbia was already in
the prior list — just unfindable without search.
Bonus: flag emojis computed from ISO-3166 alpha-2 codes (no asset files).
Search matches name + code + dial-prefix so "+41" or "CH" both find
Switzerland.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:38:36 +02:00
|
|
|
{ code: 'YE', name: 'Yemen', dial: '+967' },
|
|
|
|
|
{ code: 'ZM', name: 'Zambia', dial: '+260' },
|
|
|
|
|
{ code: 'ZW', name: 'Zimbabwe', dial: '+263' },
|
2026-05-21 23:41:19 +02:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function dialFor(code: string): string {
|
|
|
|
|
return COUNTRIES.find((c) => c.code === code)?.dial ?? '+41';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Combine a dial code and a locally-typed number into strict E.164. */
|
|
|
|
|
function toE164(dial: string, local: string): string {
|
|
|
|
|
const digits = local.replace(/\D/g, '').replace(/^0+/, '');
|
|
|
|
|
return dial + digits;
|
|
|
|
|
}
|
|
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
function errCode(err: unknown): string {
|
|
|
|
|
const detail = (err as { detail?: { error?: string } }).detail;
|
|
|
|
|
return detail?.error ?? (err as Error).message ?? 'unknown';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 00:30:20 +02:00
|
|
|
export default function LoginPage() {
|
2026-05-25 18:51:57 +02:00
|
|
|
const [providers, setProviders] = useState({
|
|
|
|
|
google: false,
|
|
|
|
|
github: false,
|
|
|
|
|
sms: false,
|
|
|
|
|
email: false,
|
|
|
|
|
});
|
2026-07-08 23:00:25 +02:00
|
|
|
// Phone sign-in is deliberately collapsed behind a text link whenever any
|
|
|
|
|
// other provider (OAuth or email) is available — a developer evaluating the
|
|
|
|
|
// product should never see a phone-number field as the front door. It only
|
|
|
|
|
// renders expanded when SMS is the sole configured provider.
|
|
|
|
|
const [phoneOpen, setPhoneOpen] = useState(false);
|
2026-07-08 23:13:53 +02:00
|
|
|
const [providersState, setProvidersState] = useState<'loading' | 'ready' | 'failed'>('loading');
|
2026-05-19 00:30:20 +02:00
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-05-21 00:26:44 +02:00
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
// Email magic-link
|
|
|
|
|
const [email, setEmail] = useState('');
|
|
|
|
|
const [emailState, setEmailState] = useState<'idle' | 'sending' | 'sent'>('idle');
|
2026-05-21 00:26:44 +02:00
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
// SMS one-time code
|
2026-05-21 23:41:19 +02:00
|
|
|
const [country, setCountry] = useState('CH');
|
|
|
|
|
const [phoneLocal, setPhoneLocal] = useState('');
|
|
|
|
|
const [sentTo, setSentTo] = useState('');
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
const [code, setCode] = useState('');
|
|
|
|
|
const [smsStep, setSmsStep] = useState<'phone' | 'code'>('phone');
|
|
|
|
|
const [smsBusy, setSmsBusy] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-25 18:51:57 +02:00
|
|
|
apiFetch<{ google: boolean; github: boolean; sms: boolean; email: boolean }>(
|
|
|
|
|
'/v1/auth/providers',
|
|
|
|
|
)
|
|
|
|
|
.then((p) => {
|
|
|
|
|
setProviders(p);
|
2026-07-08 23:13:53 +02:00
|
|
|
setProvidersState('ready');
|
2026-07-08 23:00:25 +02:00
|
|
|
// SMS as the only provider → show the phone form directly (no point
|
|
|
|
|
// hiding the sole sign-in method behind a toggle).
|
|
|
|
|
if (p.sms && !p.email && !p.google && !p.github) setPhoneOpen(true);
|
2026-05-25 18:51:57 +02:00
|
|
|
})
|
2026-07-08 23:13:53 +02:00
|
|
|
.catch(() => {
|
|
|
|
|
// If the providers lookup fails, the page must not strand the visitor
|
|
|
|
|
// with zero sign-in methods — fall back to SMS + phone form expanded
|
|
|
|
|
// (the one flow that has no provider-specific client config) and say
|
|
|
|
|
// why the other options are missing.
|
|
|
|
|
setProviders({ google: false, github: false, sms: true, email: false });
|
|
|
|
|
setPhoneOpen(true);
|
|
|
|
|
setProvidersState('failed');
|
|
|
|
|
setError('Some sign-in options could not be loaded. Reload the page to try again.');
|
|
|
|
|
});
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
const err = new URLSearchParams(window.location.search).get('error');
|
|
|
|
|
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
|
2026-05-21 00:26:44 +02:00
|
|
|
}, []);
|
2026-05-19 00:30:20 +02:00
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
async function sendMagicLink(e: React.FormEvent) {
|
2026-05-19 00:30:20 +02:00
|
|
|
e.preventDefault();
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
setEmailState('sending');
|
2026-05-19 00:30:20 +02:00
|
|
|
setError(null);
|
|
|
|
|
try {
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
await apiFetch('/v1/auth/magic-link', { method: 'POST', body: JSON.stringify({ email }) });
|
|
|
|
|
setEmailState('sent');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setEmailState('idle');
|
|
|
|
|
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the link.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function requestSmsCode(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setSmsBusy(true);
|
|
|
|
|
setError(null);
|
2026-05-21 23:41:19 +02:00
|
|
|
const full = toE164(dialFor(country), phoneLocal);
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
try {
|
2026-05-21 23:41:19 +02:00
|
|
|
await apiFetch('/v1/auth/sms/request', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: JSON.stringify({ phone: full }),
|
|
|
|
|
});
|
|
|
|
|
setSentTo(full);
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
setSmsStep('code');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(ERROR_COPY[errCode(err)] ?? 'Could not send the SMS.');
|
|
|
|
|
} finally {
|
|
|
|
|
setSmsBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function verifySmsCode(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setSmsBusy(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
await apiFetch('/v1/auth/sms/verify', {
|
2026-05-19 00:30:20 +02:00
|
|
|
method: 'POST',
|
2026-05-21 23:41:19 +02:00
|
|
|
body: JSON.stringify({ phone: sentTo, code }),
|
2026-05-19 00:30:20 +02:00
|
|
|
});
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
window.location.href = '/dashboard';
|
2026-05-19 00:30:20 +02:00
|
|
|
} catch (err) {
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
setError(ERROR_COPY[errCode(err)] ?? 'Could not verify the code.');
|
|
|
|
|
setSmsBusy(false);
|
2026-05-19 00:30:20 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
const hasOAuth = providers.google || providers.github;
|
|
|
|
|
|
2026-05-19 00:30:20 +02:00
|
|
|
return (
|
|
|
|
|
<div className="flex min-h-screen items-center justify-center px-6">
|
|
|
|
|
<div className="w-full max-w-sm">
|
|
|
|
|
<Logo className="mb-10" />
|
|
|
|
|
<h1 className="text-[20px] font-semibold tracking-tight">Sign in to your workspace</h1>
|
|
|
|
|
<p className="mt-1 text-[13px] text-[--color-fg-muted]">
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
Passwordless — pick whichever is easiest.
|
2026-05-19 00:30:20 +02:00
|
|
|
</p>
|
|
|
|
|
|
2026-07-08 23:13:53 +02:00
|
|
|
{/* Providers still loading → neutral skeleton instead of a blank card
|
|
|
|
|
(previously nothing rendered until the fetch resolved). */}
|
|
|
|
|
{providersState === 'loading' && (
|
|
|
|
|
<div aria-hidden className="mt-7 space-y-2">
|
|
|
|
|
<div className="h-10 w-full animate-pulse rounded-md bg-[--color-bg-elevated]" />
|
|
|
|
|
<div className="h-10 w-full animate-pulse rounded-md bg-[--color-bg-elevated]" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
{hasOAuth && (
|
|
|
|
|
<div className="mt-7 space-y-2">
|
|
|
|
|
{providers.google && (
|
|
|
|
|
<a
|
|
|
|
|
href={apiUrl('/v1/auth/google')}
|
|
|
|
|
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
|
|
|
|
>
|
|
|
|
|
<GoogleIcon />
|
|
|
|
|
Continue with Google
|
|
|
|
|
</a>
|
2026-05-21 00:26:44 +02:00
|
|
|
)}
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
{providers.github && (
|
|
|
|
|
<a
|
|
|
|
|
href={apiUrl('/v1/auth/github')}
|
|
|
|
|
className="flex h-10 w-full items-center justify-center gap-2.5 rounded-md border border-[--color-border] bg-[--color-bg-elevated] text-[13px] font-medium text-[--color-fg] transition-colors duration-200 hover:border-[--color-border-strong]"
|
|
|
|
|
>
|
|
|
|
|
<GitHubIcon />
|
|
|
|
|
Continue with GitHub
|
|
|
|
|
</a>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-08 23:00:25 +02:00
|
|
|
{hasOAuth && (providers.email || providers.sms) && (
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<div className="my-5 flex items-center gap-3">
|
|
|
|
|
<span className="h-px flex-1 bg-[--color-border]" />
|
|
|
|
|
<span className="text-[11px] uppercase tracking-wider text-[--color-fg-subtle]">
|
|
|
|
|
or
|
|
|
|
|
</span>
|
|
|
|
|
<span className="h-px flex-1 bg-[--color-border]" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-25 18:51:57 +02:00
|
|
|
<div className={providers.sms || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
|
2026-07-08 23:00:25 +02:00
|
|
|
{providers.email && !phoneOpen && emailState !== 'sent' && (
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<form onSubmit={sendMagicLink} className="space-y-3">
|
2026-05-21 00:26:44 +02:00
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label htmlFor="email">Email</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="email"
|
|
|
|
|
type="email"
|
|
|
|
|
required
|
|
|
|
|
autoComplete="email"
|
|
|
|
|
value={email}
|
|
|
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
|
|
|
placeholder="you@company.com"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="lg"
|
|
|
|
|
className="w-full"
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
disabled={emailState === 'sending'}
|
2026-05-21 00:26:44 +02:00
|
|
|
>
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
{emailState === 'sending' ? 'Sending…' : 'Send magic link'}
|
2026-05-21 00:26:44 +02:00
|
|
|
</Button>
|
|
|
|
|
</form>
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
)}
|
|
|
|
|
|
2026-07-08 23:00:25 +02:00
|
|
|
{providers.email && !phoneOpen && emailState === 'sent' && (
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<div className="panel p-4">
|
|
|
|
|
<p className="text-[13px]">
|
|
|
|
|
Magic link sent to <span className="mono">{email}</span>.
|
|
|
|
|
</p>
|
|
|
|
|
<p className="mt-1.5 text-[12px] text-[--color-fg-muted]">
|
|
|
|
|
Open it on this device to finish signing in.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-08 23:00:25 +02:00
|
|
|
{providers.sms && phoneOpen && smsStep === 'phone' && (
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<form onSubmit={requestSmsCode} className="space-y-3">
|
|
|
|
|
<div className="space-y-1.5">
|
2026-05-21 23:41:19 +02:00
|
|
|
<Label htmlFor="country">Country</Label>
|
2026-07-08 23:00:25 +02:00
|
|
|
<CountryPicker countries={COUNTRIES} value={country} onChange={setCountry} />
|
2026-05-21 23:41:19 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<Label htmlFor="phone" hint={dialFor(country)}>
|
|
|
|
|
Phone number
|
|
|
|
|
</Label>
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<Input
|
|
|
|
|
id="phone"
|
|
|
|
|
type="tel"
|
|
|
|
|
inputMode="tel"
|
|
|
|
|
required
|
2026-05-21 23:41:19 +02:00
|
|
|
autoComplete="tel-national"
|
|
|
|
|
value={phoneLocal}
|
|
|
|
|
onChange={(e) => setPhoneLocal(e.target.value)}
|
|
|
|
|
placeholder="79 123 45 67"
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="lg"
|
|
|
|
|
className="w-full"
|
|
|
|
|
disabled={smsBusy}
|
|
|
|
|
>
|
|
|
|
|
{smsBusy ? 'Sending…' : 'Send code'}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-08 23:00:25 +02:00
|
|
|
{providers.sms && phoneOpen && smsStep === 'code' && (
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
<form onSubmit={verifySmsCode} className="space-y-3">
|
|
|
|
|
<div className="space-y-1.5">
|
2026-05-21 23:41:19 +02:00
|
|
|
<Label htmlFor="code" hint={`sent to ${sentTo}`}>
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
6-digit code
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="code"
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
required
|
|
|
|
|
maxLength={6}
|
|
|
|
|
value={code}
|
|
|
|
|
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
|
|
|
|
|
placeholder="123456"
|
|
|
|
|
className="mono tracking-[0.3em]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="lg"
|
|
|
|
|
className="w-full"
|
|
|
|
|
disabled={smsBusy || code.length !== 6}
|
|
|
|
|
>
|
|
|
|
|
{smsBusy ? 'Verifying…' : 'Verify & sign in'}
|
|
|
|
|
</Button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSmsStep('phone');
|
|
|
|
|
setCode('');
|
|
|
|
|
setError(null);
|
|
|
|
|
}}
|
|
|
|
|
className="w-full text-[12px] text-[--color-fg-muted] transition-colors hover:text-[--color-fg]"
|
|
|
|
|
>
|
|
|
|
|
← Use a different number
|
|
|
|
|
</button>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{error && <p className="mt-3 text-[12px] text-[--color-danger]">{error}</p>}
|
2026-07-08 23:00:25 +02:00
|
|
|
|
|
|
|
|
{/* Phone sign-in stays available but demoted: expand link when any
|
|
|
|
|
other provider exists, collapse link to get back. */}
|
|
|
|
|
{providers.sms && !phoneOpen && (hasOAuth || providers.email) && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setPhoneOpen(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
}}
|
|
|
|
|
className="mt-4 w-full text-center text-[12px] text-[--color-fg-muted] underline-offset-2 transition-colors hover:text-[--color-fg] hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Sign in with phone number instead
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{providers.sms && phoneOpen && (hasOAuth || providers.email) && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setPhoneOpen(false);
|
|
|
|
|
setSmsStep('phone');
|
|
|
|
|
setCode('');
|
|
|
|
|
setError(null);
|
|
|
|
|
}}
|
|
|
|
|
className="mt-4 w-full text-center text-[12px] text-[--color-fg-muted] underline-offset-2 transition-colors hover:text-[--color-fg] hover:underline"
|
|
|
|
|
>
|
|
|
|
|
← Use another sign-in method
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
</div>
|
2026-05-19 00:30:20 +02:00
|
|
|
|
|
|
|
|
<div className="mt-8 text-[12px] text-[--color-fg-subtle]">
|
|
|
|
|
<Link href="/" className="transition-colors hover:text-[--color-fg]">
|
|
|
|
|
← Back to home
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-21 00:26:44 +02:00
|
|
|
|
|
|
|
|
function GoogleIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 18 18" aria-hidden="true">
|
|
|
|
|
<path
|
|
|
|
|
fill="#4285F4"
|
|
|
|
|
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615Z"
|
|
|
|
|
/>
|
|
|
|
|
<path
|
|
|
|
|
fill="#34A853"
|
|
|
|
|
d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18Z"
|
|
|
|
|
/>
|
|
|
|
|
<path
|
|
|
|
|
fill="#FBBC05"
|
|
|
|
|
d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332Z"
|
|
|
|
|
/>
|
|
|
|
|
<path
|
|
|
|
|
fill="#EA4335"
|
|
|
|
|
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58Z"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|
feat(auth): GitHub OAuth login + SMS one-time-code login
GitHub: /v1/auth/github + /callback — authorization-code flow, fetches
the verified primary email via /user/emails, reuses upsertOAuthLogin.
SMS: phone is now a first-class login identity.
- schema: users.email nullable, users.phone added, new sms_codes table.
- @bmm/auth: issueSmsCode / consumeSmsCode — 6-digit code, hashed at
rest, 10-min TTL, per-phone rate limit, 5-attempt cap, get-or-create
user by phone.
- apps/api: /v1/auth/sms/request + /verify, Twilio REST send (no SDK),
per-IP throttle. /v1/auth/providers now reports google/github/sms.
- login UI: Google + GitHub buttons, Email|Phone toggle, two-step SMS
(number -> 6-digit code with one-time-code autofill).
SMS link was rejected in favour of an OTP code — carrier link-scanners
consume magic-link tokens before the user taps them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:59:58 +02:00
|
|
|
|
|
|
|
|
function GitHubIcon() {
|
|
|
|
|
return (
|
|
|
|
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
|
|
|
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
}
|