diff --git a/manifest.json b/manifest.json index 06589f4..d6dece3 100644 --- a/manifest.json +++ b/manifest.json @@ -30,4 +30,4 @@ "email": "michio.haiyaku@gmail.com" }, "homepage_url": "https://github.com/michioxd/luckit" -} \ No newline at end of file +} diff --git a/package.json b/package.json index eb34124..9382b6c 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,10 @@ "@fontsource/manrope": "^5.1.1", "@szhsin/react-menu": "^4.2.4", "clsx": "^2.1.1", + "country-flag-icons": "^1.5.14", "events": "^3.3.0", "js-md5": "^0.8.3", + "libphonenumber-js": "^1.11.18", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.4.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 601f31f..8346f70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,18 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + country-flag-icons: + specifier: ^1.5.14 + version: 1.5.14 events: specifier: ^3.3.0 version: 3.3.0 js-md5: specifier: ^0.8.3 version: 0.8.3 + libphonenumber-js: + specifier: ^1.11.18 + version: 1.11.18 react: specifier: ^18.3.1 version: 18.3.1 @@ -903,6 +909,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + country-flag-icons@1.5.14: + resolution: {integrity: sha512-GAFsVzHDu3bdAhbQ1LwBRqk/Ad8+ZzS5zU49P+lRla0KGy/V1V8ywNa1SxBOAmI/lyEOT9dfH3Q++q1lqJlvBA==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1207,6 +1216,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.11.18: + resolution: {integrity: sha512-okMm/MCoFrm1vByeVFLBdkFIXLSHy/AIK2AEGgY3eoicfWZeOZqv3GfhtQgICkzs/tqorAMm3a4GBg5qNCrqzg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2375,6 +2387,8 @@ snapshots: convert-source-map@2.0.0: optional: true + country-flag-icons@1.5.14: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2703,6 +2717,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.11.18: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 diff --git a/src/components/PhoneNumber.tsx b/src/components/PhoneNumber.tsx new file mode 100644 index 0000000..b7ff50b --- /dev/null +++ b/src/components/PhoneNumber.tsx @@ -0,0 +1,170 @@ +import { CountryCode } from "libphonenumber-js"; +import { createElement, useEffect, useMemo, useRef, useState } from "react"; +import { Menu, MenuButton, MenuItem } from "@szhsin/react-menu"; +import clsx from "clsx"; + +export default function PhoneNumberInput(props: { + defaultCountry?: string; + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + onE164ValueChange?: (value: string) => void; + onSuccessChange?: (success: boolean) => void; + className?: string; +}) { + const [libPhoneNumber, setLibPhoneNumber] = useState(null); + const [flagsHandler, setFlagsHandler] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { + import("../modules/phone/phone").then(setLibPhoneNumber); + import("../modules/phone/flags").then(setFlagsHandler); + }, []); + + const oldCursorPos = useRef(0); + const setCursorPos = useRef(NaN); + + const [internalValue, setInternalValue] = useState(props.defaultValue || ""); + const [country, setCountry] = useState(props.defaultCountry || "ZZ"); + + useEffect(() => { + if (props.defaultCountry) { + setCountry(props.defaultCountry); + } + }, [props.defaultCountry]); + + const formatter = useMemo(() => { + if (libPhoneNumber) { + const ayt = new libPhoneNumber.AsYouType(country as CountryCode); + setInternalValue(ayt.input(internalValue)); + if (ayt.isValid()) { + const international = (["800", "808", "870", "870", "878", "881", "882", "883", "888", "979"] as string[]).includes(ayt.getCallingCode() ?? ""); + if (ayt.getCountry() || international) { + setCountry(ayt.getCountry() || "ZZ"); + } + } + return ayt; + } else return null; + }, [country, internalValue, libPhoneNumber]); + + useEffect(() => { + if (typeof props.value === "string") + setInternalValue(props.value); + }, [props.value]); + + useEffect(() => { + if (libPhoneNumber) { + props.onValueChange?.(internalValue); + props.onE164ValueChange?.(formatter!.getNumberValue() ?? ""); + props.onSuccessChange?.(formatter!.isValid() === true); + } + }, [formatter, internalValue, libPhoneNumber, props]); + + useEffect(() => { + if (inputRef.current && !isNaN(setCursorPos.current)) { + inputRef.current.selectionStart = setCursorPos.current; + inputRef.current.selectionEnd = setCursorPos.current; + + setCursorPos.current = NaN; + } + }, [internalValue]); + + const selectFlagList = useMemo(() => { + if (!libPhoneNumber) return []; + + return libPhoneNumber?.getCountries().map(country => { + const FlagEl = flagsHandler?.hasFlag(country) ? flagsHandler!.Flags[country as any as keyof typeof flagsHandler.Flags] : null; + + return [( + { + setCountry(country); + if (formatter) { + formatter.reset(); + setInternalValue(formatter.input(internalValue)); + } + }}> +
+
+ {FlagEl ? : country} +
+
+ +{libPhoneNumber.getCountryCallingCode(country as CountryCode)} {(new Intl.DisplayNames([navigator.language], { type: 'region' })).of(country)} +
+
+
+ ), country] as const; + }).sort((a, b) => a[1].localeCompare(b[1])).map(x => x[0]); + }, [flagsHandler, formatter, internalValue, libPhoneNumber]); + + return ( + <> +
+ + {flagsHandler?.hasFlag(country) ? createElement(flagsHandler.Flags[country as any as keyof typeof flagsHandler.Flags], { height: 18 }) :
} + } + transition + > + {selectFlagList} +
+ { + oldCursorPos.current = e.currentTarget.selectionEnd ?? e.currentTarget.selectionStart ?? 0; + }} + onChange={e => { + if (!formatter) return; // do not allow any input if libPhoneNumber is not loaded + + const oldValue = internalValue; + let newValue = e.target.value; + + const oldValueNumber = oldValue.match(/\d|\+/g)?.join("") ?? ""; + const newValueNumber = newValue.match(/\d|\+/g)?.join("") ?? ""; + + formatter.reset(); + newValue = formatter.input(newValue); + if ((formatter.getCountry() !== country)) { + const international = (["800", "808", "870", "870", "878", "881", "882", "883", "888", "979"] as string[]).includes(formatter.getCallingCode() ?? ""); + if (formatter.getCountry() || international) { + setCountry(formatter.getCountry() || "ZZ"); + } + } + setInternalValue(newValue); + + // retain cursor position to correct number position + const cursorPos = oldCursorPos.current; + const cursorNumberPos = oldValue.slice(0, cursorPos).match(/\d|\+/g)?.length ?? 0; + let newCursorNumberPos = cursorNumberPos; + + // set new cursor position + newCursorNumberPos += newValueNumber.length - oldValueNumber.length; + + let tmpCursorNumberPos = newCursorNumberPos; + let newCursorPos = 0; + for (const char of newValue) { + if (tmpCursorNumberPos === 0) { + break; + } + + newCursorPos++; + + if (char.match(/\d|\+/)) { + tmpCursorNumberPos--; + } + } + + setCursorPos.current = newCursorPos; + }} + ref={inputRef} + /> +
+ + ) +} \ No newline at end of file diff --git a/src/index.scss b/src/index.scss index 7fe138f..5b95873 100644 --- a/src/index.scss +++ b/src/index.scss @@ -185,4 +185,23 @@ a { align-items: center; justify-content: center; } +} + +.szh-menu { + background: #000000bb; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: 0px 0px 10px 0px #0000002c; + border: 1px solid #333333b0; + border-radius: 5px; + + .szh-menu__item { + color: var(--color); + padding: 0.3rem 0.5rem; + + &:hover, + &.szh-menu__item--hover { + background: #222222bb; + } + } } \ No newline at end of file diff --git a/src/modules/phone/flags.ts b/src/modules/phone/flags.ts new file mode 100644 index 0000000..a45a48a --- /dev/null +++ b/src/modules/phone/flags.ts @@ -0,0 +1,3 @@ +export * from "country-flag-icons"; +import * as Flags from "country-flag-icons/react/3x2"; +export { Flags }; \ No newline at end of file diff --git a/src/modules/phone/phone.ts b/src/modules/phone/phone.ts new file mode 100644 index 0000000..66967fb --- /dev/null +++ b/src/modules/phone/phone.ts @@ -0,0 +1 @@ +export * from "libphonenumber-js/max"; \ No newline at end of file diff --git a/src/screens/Login.module.scss b/src/screens/Login.module.scss index e56042b..39979ab 100644 --- a/src/screens/Login.module.scss +++ b/src/screens/Login.module.scss @@ -42,8 +42,11 @@ .Input { width: 100%; max-width: 350px; + transition: 0.2s; + border-radius: 10px; + border-color: #000; - &[type="text"] { + &[name="email"] { border-radius: 15px 15px 0px 0px; border-bottom: 1px solid #000; } @@ -65,6 +68,14 @@ gap: 0.5rem; } } + + .anotherMethod { + font-size: 12px; + margin-top: 0.6rem; + font-weight: 600; + color: var(--accent); + cursor: pointer; + } } .forkMe { diff --git a/src/screens/Login.tsx b/src/screens/Login.tsx index ea062b6..023b05a 100644 --- a/src/screens/Login.tsx +++ b/src/screens/Login.tsx @@ -2,12 +2,13 @@ import { useCallback, useState } from "react"; import LuckitLogo from "../components/Logo"; import cls from "./Login.module.scss"; import clsx from "clsx"; -import { validateEmail } from "../utils/string"; +import { validateE164, validateEmail } from "../utils/string"; import Spinner from "../components/Spinner"; import { API, GenericError, ResponseError } from "../services/api"; import { useMainContext } from "../MainContext"; import { VscClose } from "react-icons/vsc"; import { UserType } from "../types/user"; +import PhoneNumberInput from "../components/PhoneNumber"; export default function LoginScreen() { const mainCtx = useMainContext(); const [email, setEmail] = useState(""); @@ -15,12 +16,78 @@ export default function LoginScreen() { const [showWarn, setShowWarn] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [otpLogin, setOtpLogin] = useState(false); + const [pNInput, setPNInput] = useState(""); + const [phoneNumber, setPhoneNumber] = useState(""); + const [otpCode, setOtpCode] = useState(""); + const [sentOtp, setSentOtp] = useState(false); + + const handleSendOtp = useCallback(async () => { + setError(""); + setLoading(true); + + try { + const res = await API.requestOTP(phoneNumber); + + if (res) { + setSentOtp(true); + setLoading(false); + return; + } + setError("Unable to send OTP!"); + setLoading(false); + } catch (e: any) { + const error = e as ResponseError; + setLoading(false); + setError( + error.error.message === 'INVALID_PHONE_NUMBER' ? "Invalid phone number" : + "Unable to send OTP: (" + error.error.message + ")"); + } + }, [phoneNumber]); const handleLogin = useCallback(async () => { setError(""); setShowWarn(false); setLoading(true); + if (otpLogin) { + try { + const res = await API.verifyOTP(phoneNumber, otpCode); + + if (res.token) { + const exchange = await API.exchangeOTPTokenForIDToken(res.token); + + const user = await API.getAccountInfo(exchange.idToken); + + if (!user.users[0]) { + setError("Something went wrong, please try again"); + setLoading(false); + return; + } + + chrome.storage.local.set({ + token: exchange.idToken, + refreshToken: exchange.refreshToken, + user: user.users[0] as UserType + }, () => { + chrome.runtime.sendMessage({ fetchLatestMoment: true, login: true }); + mainCtx.setLoggedIn(true); + }); + return; + } + + setError("Unable to login!"); + setLoading(false); + } catch (e: any) { + const error = e as ResponseError; + setLoading(false); + setError( + error.error.message === 'INVALID_CODE' ? "Invalid OTP code" : + "We encountered an error: (" + error.error.message + ")"); + } + return; + } + try { const res = await API.login(email, password); @@ -55,7 +122,7 @@ export default function LoginScreen() { error.error.message === "USER_DISABLED" ? "User is disabled" : "We encountered an error: (" + error.error.message + ")"); } - }, [email, mainCtx, password]); + }, [otpLogin, phoneNumber, otpCode, mainCtx, email, password]); return (
@@ -98,35 +165,77 @@ export default function LoginScreen() {

-
- setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> - + : !validateE164(phoneNumber) ? "invalid phone number" : "send otp"} + : + + } + { setOtpLogin(!otpLogin); setPhoneNumber(""); setPNInput(""); setOtpCode(""); }}> + {otpLogin ? "login with email and password" : "login with phone number"} +
+

made with luv by michioxd - fork me on github

diff --git a/src/services/api.ts b/src/services/api.ts index 4083374..df45077 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -1,4 +1,4 @@ -import { LoginPayloadType, LoginResponseType, RefreshTokenPayloadType, RefreshTokenResponseType } from "../types/auth" +import { ExchangeCustomTokenPayloadType, ExchangeCustomTokenResponseType, LoginPayloadType, LoginResponseType, RefreshTokenPayloadType, RefreshTokenResponseType, RequestPhoneOTPResponseType, VerifyPhoneOTPResponseType } from "../types/auth" import { MomentType } from "../types/moments" import { GetAccountInfoResponseType, UserInfoType } from "../types/user" @@ -63,14 +63,22 @@ export async function fetchFirebase({ } export async function fetchLocket({ - endpoint, method, body, token + endpoint, method, body, token, headers: cHeaders }: { endpoint: string, method: string, body?: any, - token?: string + token?: string, + headers?: HeadersInit }) { const headers = new Headers(); + + if (cHeaders) { + for (const [key, value] of Object.entries(cHeaders)) { + headers.append(key, value); + } + } + headers.append('Content-Type', 'application/json'); if (token) { @@ -78,7 +86,9 @@ export async function fetchLocket({ } else { await new Promise((res) => { chrome.storage.local.get("token", (data) => { - headers.append('Authorization', `Bearer ${data.token}`); + if (data.token) + headers.append('Authorization', `Bearer ${data.token}`); + res(null); }); }); @@ -117,6 +127,36 @@ export const API = { clientType: "CLIENT_TYPE_IOS" } }), + requestOTP: (phoneE164: string) => fetchLocket({ + endpoint: "sendVerificationCode", + method: "POST", + body: { + data: { + deviceModel: "iPhone12,1", + operation: "hybrid", + phone: phoneE164, + use_password_if_available: false + } + } + }), + verifyOTP: (phoneE164: string, code: string) => fetchLocket({ + endpoint: "checkVerificationCode", + method: "POST", + body: { + data: { + phone: phoneE164, + verification_code: code + } + } + }), + exchangeOTPTokenForIDToken: (token: string) => fetchFirebase({ + endpoint: "https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken", + method: "POST", + body: { + returnSecureToken: true, + token + } + }), refreshToken: (refreshToken: string) => fetchFirebase({ endpoint: "https://securetoken.googleapis.com/v1/token", method: "POST", diff --git a/src/types/auth.ts b/src/types/auth.ts index 9983c1d..e2b627c 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -30,4 +30,26 @@ export interface RefreshTokenResponseType { id_token: string; user_id: string; project_id: string; -} \ No newline at end of file +} + +export interface RequestPhoneOTPResponseType { + method: string; + provider: string; +} + +export interface VerifyPhoneOTPResponseType { + token: string; +} + +export interface ExchangeCustomTokenPayloadType { + returnSecureToken: boolean; + token: string; +} + +export interface ExchangeCustomTokenResponseType { + expiresIn: string; + idToken: string; + isNewUser: boolean; + kind: string; + refreshToken: string; +} diff --git a/src/utils/string.ts b/src/utils/string.ts index dc8be9e..53d78ea 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -12,6 +12,11 @@ export function parseJwt(token: string) { return JSON.parse(jsonPayload); } +export function validateE164(phoneNumber: string) { + const reg = /^\+[1-9]\d{1,14}$/; + return reg.test(phoneNumber); +} + export const timeSinceOf = function (date: number) { const text = [