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,
|
|
|
|
|
});
|
|
|
|
|
// Default to SMS — email is off by default until an SMTP/Resend provider
|
|
|
|
|
// is wired. The effect below flips to 'email' if the backend says it's on.
|
|
|
|
|
const [method, setMethod] = useState<'email' | 'phone'>('phone');
|
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);
|
|
|
|
|
// Pick the most-likely method up-front: email if enabled, else SMS.
|
|
|
|
|
if (p.email) setMethod('email');
|
|
|
|
|
else if (p.sms) setMethod('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
|
|
|
.catch(() => undefined);
|
|
|
|
|
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>
|
|
|
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{hasOAuth && (
|
|
|
|
|
<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
|
|
|
{/* Tab toggle only shown when BOTH email and SMS are enabled — if just
|
|
|
|
|
one is configured, that method's form renders directly without a
|
|
|
|
|
useless one-tab toggle. */}
|
|
|
|
|
{providers.sms && providers.email && (
|
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={`flex gap-1 rounded-md border border-[--color-border] p-1 ${hasOAuth ? '' : 'mt-7'}`}
|
|
|
|
|
>
|
|
|
|
|
{(['email', 'phone'] as const).map((m) => (
|
|
|
|
|
<button
|
|
|
|
|
key={m}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setMethod(m);
|
|
|
|
|
setError(null);
|
|
|
|
|
}}
|
|
|
|
|
className={`h-7 flex-1 rounded text-[12px] font-medium transition-colors ${
|
|
|
|
|
method === m
|
|
|
|
|
? 'bg-[--color-bg-subtle] text-[--color-fg]'
|
|
|
|
|
: 'text-[--color-fg-muted] hover:text-[--color-fg]'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{m === 'email' ? 'Email' : 'Phone'}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-25 18:51:57 +02:00
|
|
|
<div className={providers.sms || providers.email ? 'mt-4' : hasOAuth ? '' : 'mt-7'}>
|
|
|
|
|
{method === 'email' && providers.email && 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-05-25 18:51:57 +02:00
|
|
|
{method === 'email' && providers.email && 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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{method === 'phone' && smsStep === 'phone' && (
|
|
|
|
|
<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>
|
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
|
|
|
<CountryPicker
|
|
|
|
|
countries={COUNTRIES}
|
2026-05-21 23:41:19 +02:00
|
|
|
value={country}
|
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
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{method === 'phone' && smsStep === 'code' && (
|
|
|
|
|
<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>}
|
|
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|