diff --git a/web/.prettierignore b/web/.prettierignore
index 602eea6572491741c4d7ef2e081b397c80afa832..c94d62482f0770a85a48abc44345be9efb849855 100644
--- a/web/.prettierignore
+++ b/web/.prettierignore
@@ -1,3 +1,2 @@
thirdparty/
public/
-*.md
diff --git a/web/.prettierrc.json b/web/.prettierrc.json
index 8b06525972064c87f87a5e109a74b7f1b5cbb718..7cf8c86c774c3af28c43a1e3b928d041b8641b12 100644
--- a/web/.prettierrc.json
+++ b/web/.prettierrc.json
@@ -1,5 +1,6 @@
{
"tabWidth": 4,
+ "proseWrap": "always",
"plugins": [
"prettier-plugin-organize-imports",
"prettier-plugin-packagejson"
diff --git a/web/README.md b/web/README.md
index 908676c558d05cfe074637113f7fcd68d334579b..d33c0390401eae3850158f60878e1466831dfd70 100644
--- a/web/README.md
+++ b/web/README.md
@@ -4,8 +4,8 @@ Source code for Ente's various web apps and supporting websites.
Live versions are at:
-* Ente Photos: [web.ente.io](https://web.ente.io)
-* Ente Auth: [auth.ente.io](https://auth.ente.io)
+- Ente Photos: [web.ente.io](https://web.ente.io)
+- Ente Auth: [auth.ente.io](https://auth.ente.io)
To know more about Ente, see [our main README](../README.md) or visit
[ente.io](https://ente.io).
@@ -49,17 +49,17 @@ For more details about development workflows, see [docs/dev](docs/dev.md).
As a brief overview, this directory contains the following apps:
-* `apps/photos`: A fully functional web client for Ente Photos.
-* `apps/auth`: A view only client for Ente Auth. Currently you can only view
- your 2FA codes using this web app. For adding and editing your 2FA codes,
- please use the Ente Auth [mobile/desktop app](../auth/README.md) instead.
+- `apps/photos`: A fully functional web client for Ente Photos.
+- `apps/auth`: A view only client for Ente Auth. Currently you can only view
+ your 2FA codes using this web app. For adding and editing your 2FA codes,
+ please use the Ente Auth [mobile/desktop app](../auth/README.md) instead.
These two are the public facing apps. There are other part of the code which are
accessed as features within the main apps, but in terms of code are
independently maintained and deployed:
-* `apps/accounts`: Passkey support (Coming soon)
-* `apps/cast`: Chromecast support (Coming soon)
+- `apps/accounts`: Passkey support (Coming soon)
+- `apps/cast`: Chromecast support (Coming soon)
> [!NOTE]
>
@@ -81,12 +81,12 @@ City coordinates from [Simple Maps](https://simplemaps.com/data/world-cities)
[](https://crowdin.com/project/ente-photos-web)
-If you're interested in helping out with translation, please visit our [Crowdin
-project](https://crowdin.com/project/ente-photos-web) to get started. Thank you
-for your support.
+If you're interested in helping out with translation, please visit our
+[Crowdin project](https://crowdin.com/project/ente-photos-web) to get started.
+Thank you for your support.
-If your language is not listed for translation, please [create a GitHub
-issue](https://github.com/ente-io/ente/issues/new?title=Request+for+New+Language+Translation&body=Language+name%3A)
+If your language is not listed for translation, please
+[create a GitHub issue](https://github.com/ente-io/ente/issues/new?title=Request+for+New+Language+Translation&body=Language+name%3A)
to have it added.
## Contribute
diff --git a/web/apps/payments/.eslintrc.js b/web/apps/payments/.eslintrc.js
index 56670a6a792194518471ce02828cc9b1bd44f260..348075cd4f1c72f62befbb6dfd917beac4d2098e 100644
--- a/web/apps/payments/.eslintrc.js
+++ b/web/apps/payments/.eslintrc.js
@@ -1,8 +1,3 @@
module.exports = {
extends: ["@/build-config/eslintrc-next"],
- parserOptions: {
- tsconfigRootDir: __dirname,
- },
- // TODO (MR): Figure out a way to not have to ignored the next config .js
- // ignorePatterns: [".eslintrc.js", "next.config.js"],
};
diff --git a/web/apps/payments/README.md b/web/apps/payments/README.md
index e3fb4fd850285afa6a69dd8c40ee4ee00fdc86d0..7554ffbb9a50181d76120fa27fb91d0dc01a7e50 100644
--- a/web/apps/payments/README.md
+++ b/web/apps/payments/README.md
@@ -5,9 +5,9 @@ Stripe's API for payments.
There are three pieces that need to be connected to have a working local setup:
-- A client app
-- This web app
-- Museum
+- A client app
+- This web app
+- Museum
### Client app
@@ -20,6 +20,7 @@ Add the following to `web/apps/photos/.env.local`:
NEXT_PUBLIC_ENTE_ENDPOINT = http://localhost:8080
NEXT_PUBLIC_ENTE_PAYMENTS_ENDPOINT = http://localhost:3001
```
+
Then start it locally
```sh
@@ -35,8 +36,8 @@ This tells it to connect to the museum and payments app running on localhost.
### Payments app
For this (payments) web app, configure it to connect to the local museum, and
-use a set of (development) Stripe keys which can be found in [Stripe's developer
-dashboard](https://dashboard.stripe.com).
+use a set of (development) Stripe keys which can be found in
+[Stripe's developer dashboard](https://dashboard.stripe.com).
Add the following to `web/apps/payments/.env.local`:
@@ -58,8 +59,8 @@ yarn dev:payments
2. Define this secret within your `musuem.yaml`
-3. Update the `whitelisted-redirect-urls` so that it supports redirecting to
- the locally running payments app.
+3. Update the `whitelisted-redirect-urls` so that it supports redirecting to the
+ locally running payments app.
Assuming that your local payments app is running on `localhost:3001`, your
`server/museum.yaml` should look as follows.
@@ -69,7 +70,9 @@ stripe:
us:
key: stripe_dev_key
webhook-secret: stripe_dev_webhook_secret
- whitelisted-redirect-urls: ["http://localhost:3000/gallery", "http://192.168.1.2:3001/frameRedirect"]
+ whitelisted-redirect-urls:
+ - "http://localhost:3000/gallery"
+ - "http://192.168.1.2:3001/frameRedirect"
path:
success: ?status=success&session_id={CHECKOUT_SESSION_ID}
cancel: ?status=fail&reason=canceled
@@ -80,7 +83,7 @@ Make sure you have test plans available for museum to use, by placing them in
Finally, start museum, for example:
-```
+```sh
docker compose up
```
diff --git a/web/apps/payments/package.json b/web/apps/payments/package.json
index e73f26c6c83f04e6ba35563e947159aaac0b6643..e594509ead002d3bc5db7ef6ac26094fefb8bbef 100644
--- a/web/apps/payments/package.json
+++ b/web/apps/payments/package.json
@@ -4,7 +4,6 @@
"private": true,
"dependencies": {
"@/next": "*",
- "@stripe/stripe-js": "^1.17.0",
- "axios": "^1.6.7"
+ "@stripe/stripe-js": "^1.17.0"
}
}
diff --git a/web/apps/payments/src/pages/404.tsx b/web/apps/payments/src/pages/404.tsx
index 2f6f5d9d088af0ef0505117a3eeca295f1b62da5..8654d4ed710462f30199d4e6a66d8c8d09c1d69b 100644
--- a/web/apps/payments/src/pages/404.tsx
+++ b/web/apps/payments/src/pages/404.tsx
@@ -1,7 +1,9 @@
import { Container } from "components/Container";
import React from "react";
-import constants from "utils/strings";
+import S from "utils/strings";
-export default function Home() {
- return {constants.NOT_FOUND};
-}
+const Page: React.FC = () => {
+ return {S.error_404};
+};
+
+export default Page;
diff --git a/web/apps/payments/src/pages/_app.tsx b/web/apps/payments/src/pages/_app.tsx
index 9d57985e9f3c21789a44c630be881bc4e99f8fc2..0beb8efb5170619593397c03a433ec61c699dfd9 100644
--- a/web/apps/payments/src/pages/_app.tsx
+++ b/web/apps/payments/src/pages/_app.tsx
@@ -1,18 +1,18 @@
import type { AppProps } from "next/app";
import Head from "next/head";
import React from "react";
-import constants from "utils/strings";
+import S from "utils/strings";
import "../styles/globals.css";
-function MyApp({ Component, pageProps }: AppProps) {
+const MyApp = ({ Component, pageProps }: AppProps): React.JSX.Element => {
return (
<>
- {constants.TITLE}
+ {S.title}
>
);
-}
+};
export default MyApp;
diff --git a/web/apps/payments/src/pages/desktop-redirect.tsx b/web/apps/payments/src/pages/desktop-redirect.tsx
index 89df55bb6386b38746a551869911e4d5ecd78287..2dc5d0bb395f55f858256b6e10bbe1557551aa5d 100644
--- a/web/apps/payments/src/pages/desktop-redirect.tsx
+++ b/web/apps/payments/src/pages/desktop-redirect.tsx
@@ -1,9 +1,9 @@
import { Container } from "components/Container";
import { Spinner } from "components/Spinner";
-import * as React from "react";
+import React, { useEffect } from "react";
-export default function DesktopRedirect() {
- React.useEffect(() => {
+const Page: React.FC = () => {
+ useEffect(() => {
const currentURL = new URL(window.location.href);
const desktopRedirectURL = new URL("ente://app/gallery");
desktopRedirectURL.search = currentURL.search;
@@ -15,4 +15,6 @@ export default function DesktopRedirect() {
);
-}
+};
+
+export default Page;
diff --git a/web/apps/payments/src/pages/index.tsx b/web/apps/payments/src/pages/index.tsx
index 64c8b96a7a4651e60992fa4677ab6ab3a1f817a1..eef49fb7c5f10c1278fa3bf37235942c430a3a81 100644
--- a/web/apps/payments/src/pages/index.tsx
+++ b/web/apps/payments/src/pages/index.tsx
@@ -1,42 +1,19 @@
import { Container } from "components/Container";
import { Spinner } from "components/Spinner";
-import * as React from "react";
-import { parseAndHandleRequest } from "services/billingService";
-import { CUSTOM_ERROR } from "utils/error";
-import constants from "utils/strings";
+import React, { useEffect } from "react";
+import { parseAndHandleRequest } from "services/billing-service";
+import S from "utils/strings";
-export default function Home() {
- const [errorMessageView, setErrorMessageView] = React.useState(false);
- const [loading, setLoading] = React.useState(false);
+const Page: React.FC = () => {
+ const [failed, setFailed] = React.useState(false);
- React.useEffect(() => {
- async function main() {
- try {
- setLoading(true);
- await parseAndHandleRequest();
- } catch (e: unknown) {
- if (
- e instanceof Error &&
- e.message === CUSTOM_ERROR.DIRECT_OPEN_WITH_NO_QUERY_PARAMS
- ) {
- window.location.href = "https://ente.io";
- } else {
- setErrorMessageView(true);
- }
- }
- }
- // TODO: audit
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- main();
+ useEffect(() => {
+ parseAndHandleRequest().catch(() => {
+ setFailed(true);
+ });
}, []);
- return (
-
- {errorMessageView ? (
- {constants.SOMETHING_WENT_WRONG}
- ) : (
- loading &&
- )}
-
- );
-}
+ return {failed ? S.error_generic : };
+};
+
+export default Page;
diff --git a/web/apps/payments/src/services/HTTPService.ts b/web/apps/payments/src/services/HTTPService.ts
deleted file mode 100644
index 834a18ae69f6dd98d4af237fc280e1e6ffc0ae7c..0000000000000000000000000000000000000000
--- a/web/apps/payments/src/services/HTTPService.ts
+++ /dev/null
@@ -1,185 +0,0 @@
-// TODO: Audit
-/* eslint-disable @typescript-eslint/no-unsafe-argument */
-/* eslint-disable @typescript-eslint/no-unsafe-assignment */
-/* eslint-disable @typescript-eslint/prefer-promise-reject-errors */
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-/* eslint-disable @typescript-eslint/consistent-indexed-object-style */
-/* eslint-disable @typescript-eslint/no-explicit-any */
-
-import axios, { AxiosRequestConfig } from "axios";
-
-interface IHTTPHeaders {
- [headerKey: string]: any;
-}
-
-interface IQueryPrams {
- [paramName: string]: any;
-}
-
-/**
- * Service to manage all HTTP calls.
- */
-class HTTPService {
- constructor() {
- axios.interceptors.response.use(
- (response) => Promise.resolve(response),
- (err) => {
- if (!err.response) {
- return Promise.reject(err);
- }
- const { response } = err;
- return Promise.reject(response);
- },
- );
- }
-
- /**
- * header object to be append to all api calls.
- */
- private headers: IHTTPHeaders = {
- "content-type": "application/json",
- };
-
- /**
- * Sets the headers to the given object.
- */
- public setHeaders(headers: IHTTPHeaders) {
- this.headers = headers;
- }
-
- /**
- * Adds a header to list of headers.
- */
- public appendHeader(key: string, value: string) {
- this.headers = {
- ...this.headers,
- [key]: value,
- };
- }
-
- /**
- * Removes the given header.
- */
- public removeHeader(key: string) {
- this.headers[key] = undefined;
- }
-
- /**
- * Returns axios interceptors.
- */
- // eslint-disable-next-line class-methods-use-this
- public getInterceptors() {
- return axios.interceptors;
- }
-
- /**
- * Generic HTTP request.
- * This is done so that developer can use any functionality
- * provided by axios. Here, only the set headers are spread
- * over what was sent in config.
- */
- public async request(config: AxiosRequestConfig, customConfig?: any) {
- // eslint-disable-next-line no-param-reassign
- config.headers = {
- ...this.headers,
- ...config.headers,
- };
- if (customConfig?.cancel) {
- config.cancelToken = new axios.CancelToken(
- (c) => (customConfig.cancel.exec = c),
- );
- }
- return await axios({ ...config, ...customConfig });
- }
-
- /**
- * Get request.
- */
- public get(
- url: string,
- params?: IQueryPrams,
- headers?: IHTTPHeaders,
- customConfig?: any,
- ) {
- return this.request(
- {
- headers,
- method: "GET",
- params,
- url,
- },
- customConfig,
- );
- }
-
- /**
- * Post request
- */
- public post(
- url: string,
- data?: any,
- params?: IQueryPrams,
- headers?: IHTTPHeaders,
- customConfig?: any,
- ) {
- return this.request(
- {
- data,
- headers,
- method: "POST",
- params,
- url,
- },
- customConfig,
- );
- }
-
- /**
- * Put request
- */
- public put(
- url: string,
- data: any,
- params?: IQueryPrams,
- headers?: IHTTPHeaders,
- customConfig?: any,
- ) {
- return this.request(
- {
- data,
- headers,
- method: "PUT",
- params,
- url,
- },
- customConfig,
- );
- }
-
- /**
- * Delete request
- */
- public delete(
- url: string,
- data: any,
- params?: IQueryPrams,
- headers?: IHTTPHeaders,
- customConfig?: any,
- ) {
- return this.request(
- {
- data,
- headers,
- method: "DELETE",
- params,
- url,
- },
- customConfig,
- );
- }
-}
-
-// Creates a Singleton Service.
-// This will help me maintain common headers / functionality
-// at a central place.
-export default new HTTPService();
diff --git a/web/apps/payments/src/services/billing-service.ts b/web/apps/payments/src/services/billing-service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..30a7bfb4a4037c933c8870e7b73869ec60020f00
--- /dev/null
+++ b/web/apps/payments/src/services/billing-service.ts
@@ -0,0 +1,302 @@
+import { loadStripe } from "@stripe/stripe-js";
+
+/**
+ * Communicate with Stripe using their JS SDK, and redirect back to the client.
+ *
+ * All necessary parameters are obtained by parsing the request parameters.
+ *
+ * In case of unrecoverable errors, this function will throw. Otherwise it will
+ * redirect to the client or to some fallback URL.
+ */
+export const parseAndHandleRequest = async () => {
+ try {
+ const urlParams = new URLSearchParams(window.location.search);
+ const productID = urlParams.get("productID");
+ const paymentToken = urlParams.get("paymentToken");
+ const action = urlParams.get("action");
+ const redirectURL = urlParams.get("redirectURL");
+
+ if (!action && !paymentToken && !productID && !redirectURL) {
+ // Maybe someone attempted to directly open this page in their
+ // browser. Not much we can do, just redirect them to the main site.
+ console.log(
+ "None of the required query parameters were supplied, redirecting to the ente.io",
+ );
+ redirectHome();
+ return;
+ }
+
+ if (!action || !paymentToken || !productID || !redirectURL) {
+ throw Error("Required query parameter was not provided");
+ }
+
+ switch (action) {
+ case "buy":
+ await buySubscription(productID, paymentToken, redirectURL);
+ break;
+ case "update":
+ await updateSubscription(productID, paymentToken, redirectURL);
+ break;
+ default:
+ throw Error(`Unsupported action ${action}`);
+ }
+ } catch (e) {
+ console.error(e);
+ throw e;
+ }
+};
+
+const apiOrigin =
+ process.env.NEXT_PUBLIC_ENTE_ENDPOINT ?? "https://api.ente.io";
+
+type StripeAccountCountry = "IN" | "US";
+
+const isStripeAccountCountry = (c: unknown): c is StripeAccountCountry =>
+ c == "IN" || c == "US";
+
+const stripePublishableKey = (accountCountry: StripeAccountCountry) => {
+ switch (accountCountry) {
+ case "IN":
+ return (
+ process.env.NEXT_PUBLIC_STRIPE_IN_PUBLISHABLE_KEY ??
+ "pk_live_51HAhqDK59oeucIMOiTI6MDDM2UWUbCAJXJCGsvjJhiO8nYJz38rQq5T4iyQLDMKxqEDUfU5Hopuj4U5U4dff23oT00fHvZeodC"
+ );
+ case "US":
+ return (
+ process.env.NEXT_PUBLIC_STRIPE_US_PUBLISHABLE_KEY ??
+ "pk_live_51LZ9P4G1ITnQlpAnrP6pcS7NiuJo3SnJ7gibjJlMRatkrd2EY1zlMVTVQG5RkSpLPbsHQzFfnEtgHnk1PiylIFkk00tC0LWXwi"
+ );
+ }
+};
+
+/** Return the {@link StripeAccountCountry} for the user. */
+const getUserStripeAccountCountry = async (paymentToken: string) => {
+ const url = `${apiOrigin}/billing/stripe-account-country`;
+ const res = await fetch(url, {
+ headers: {
+ "X-Auth-Token": paymentToken,
+ },
+ });
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
+ const json: unknown = await res.json();
+ if (json && typeof json === "object" && "stripeAccountCountry" in json) {
+ const c = json.stripeAccountCountry;
+ if (isStripeAccountCountry(c)) return c;
+ }
+ throw new Error(`Unexpected response for ${url}: ${JSON.stringify(json)}`);
+};
+
+/** Load and return the Stripe JS SDK initialized for the given country. */
+const getStripe = async (
+ redirectURL: string,
+ accountCountry: StripeAccountCountry,
+) => {
+ const publishableKey = stripePublishableKey(accountCountry);
+ try {
+ const stripe = await loadStripe(publishableKey);
+ if (!stripe) throw new Error("Failed to load Stripe");
+ return stripe;
+ } catch (e) {
+ redirectToApp(redirectURL, "fail", "stripe_error");
+ throw e;
+ }
+};
+
+/** The flow when the user wants to buy a new subscription. */
+const buySubscription = async (
+ productID: string,
+ paymentToken: string,
+ redirectURL: string,
+) => {
+ try {
+ const accountCountry = await getUserStripeAccountCountry(paymentToken);
+ const stripe = await getStripe(redirectURL, accountCountry);
+ const sessionId = await createCheckoutSession(
+ productID,
+ paymentToken,
+ redirectURL,
+ );
+ await stripe.redirectToCheckout({ sessionId });
+ } catch (e) {
+ redirectToApp(redirectURL, "fail", "server_error");
+ throw e;
+ }
+};
+
+/** Create a new checkout session on museum and return the sessionID. */
+const createCheckoutSession = async (
+ productID: string,
+ paymentToken: string,
+ redirectURL: string,
+): Promise => {
+ const params = new URLSearchParams({ productID, redirectURL });
+ const url = `${apiOrigin}/billing/stripe/checkout-session?${params.toString()}`;
+ const res = await fetch(url, {
+ headers: {
+ "X-Auth-Token": paymentToken,
+ },
+ });
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
+ const json: unknown = await res.json();
+ if (json && typeof json == "object" && "sessionID" in json) {
+ const sid = json.sessionID;
+ if (typeof sid == "string") return sid;
+ }
+ throw new Error(`Unexpected response for ${url}: ${JSON.stringify(json)}`);
+};
+
+const updateSubscription = async (
+ productID: string,
+ paymentToken: string,
+ redirectURL: string,
+) => {
+ try {
+ const accountCountry = await getUserStripeAccountCountry(paymentToken);
+ const stripe = await getStripe(redirectURL, accountCountry);
+ const { status, clientSecret } = await updateStripeSubscription(
+ paymentToken,
+ productID,
+ );
+ switch (status) {
+ case "success": {
+ // Subscription was updated successfully, nothing more required
+ redirectToApp(redirectURL, "success");
+ return;
+ }
+
+ case "requires_payment_method":
+ redirectToApp(redirectURL, "fail", "requires_payment_method");
+ return;
+
+ case "requires_action": {
+ const { error } = await stripe.confirmCardPayment(clientSecret);
+ if (!error) {
+ redirectToApp(redirectURL, "success");
+ } else {
+ console.error("Failed to confirm card payment", error);
+ if (error.type == "card_error") {
+ redirectToApp(
+ redirectURL,
+ "fail",
+ "requires_payment_method",
+ );
+ } else if (
+ error.type == "authentication_error" ||
+ error.code == "payment_intent_authentication_failure"
+ ) {
+ redirectToApp(
+ redirectURL,
+ "fail",
+ "authentication_failed",
+ );
+ } else {
+ redirectToApp(redirectURL, "fail");
+ }
+ }
+ return;
+ }
+ }
+ } catch (e) {
+ redirectToApp(redirectURL, "fail", "server_error");
+ throw e;
+ }
+};
+
+type PaymentStatus = "success" | "requires_action" | "requires_payment_method";
+
+const isPaymentStatus = (s: unknown): s is PaymentStatus =>
+ s == "success" || s == "requires_action" || s == "requires_payment_method";
+
+interface UpdateStripeSubscriptionResponse {
+ status: PaymentStatus;
+ clientSecret: string;
+}
+
+/**
+ * Make a request to museum to update an existing Stript subscription with
+ * {@link productID} for the user.
+ */
+async function updateStripeSubscription(
+ paymentToken: string,
+ productID: string,
+): Promise {
+ const url = `${apiOrigin}/billing/stripe/update-subscription`;
+ const res = await fetch(url, {
+ method: "POST",
+ headers: {
+ "X-Auth-Token": paymentToken,
+ },
+ body: JSON.stringify({
+ productID,
+ }),
+ });
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: HTTP ${res.status}`);
+ const json: unknown = await res.json();
+ if (json && typeof json == "object" && "result" in json) {
+ const result = json.result;
+ if (
+ result &&
+ typeof result == "object" &&
+ "status" in result &&
+ "clientSecret" in result
+ ) {
+ const status = result.status;
+ const clientSecret = result.clientSecret;
+ if (isPaymentStatus(status) && typeof clientSecret == "string") {
+ return { status, clientSecret };
+ }
+ }
+ }
+ throw new Error(`Unexpected response for ${url}: ${JSON.stringify(json)}`);
+}
+
+type RedirectStatus = "success" | "fail";
+
+type FailureReason =
+ /**
+ * Unable to authenticate card or 3DS.
+ *
+ * User should be shown button for fixing card via customer portal.
+ */
+ | "authentication_failed"
+ /**
+ * Card declined results in this error.
+ *
+ * Show button to the customer portal.
+ */
+ | "requires_payment_method"
+ /**
+ * An error in initializing the Stripe JS SDK.
+ */
+ | "stripe_error"
+ | "canceled"
+ | "server_error";
+
+/**
+ * Navigate to {@link redirectURL}, passing the given values as query params.
+ *
+ * [Note: Redirects do not interrupt script execution]
+ *
+ * I have been unable to find a documentation / reference source for this, but
+ * in practice when I test it with a following snippet
+ *
+ * const nonce = Math.random();
+ * console.log("before", nonce);
+ * window.location.href = "http://example.org";
+ * console.log("after", nonce);
+ *
+ * I observe that the code after the navigation also runs.
+ */
+const redirectToApp = (
+ redirectURL: string,
+ status: RedirectStatus,
+ reason?: FailureReason,
+) => {
+ let url = `${redirectURL}?status=${status}`;
+ if (reason) url = `${url}&reason=${reason}`;
+ window.location.href = url;
+};
+
+const redirectHome = () => {
+ window.location.href = "https://ente.io";
+};
diff --git a/web/apps/payments/src/services/billingService.ts b/web/apps/payments/src/services/billingService.ts
deleted file mode 100644
index 9224129d3d06c45c520935c9be7a86d4004da95c..0000000000000000000000000000000000000000
--- a/web/apps/payments/src/services/billingService.ts
+++ /dev/null
@@ -1,287 +0,0 @@
-// TODO: Audit this and other eslints
-/* eslint-disable @typescript-eslint/no-explicit-any */
-/* eslint-disable @typescript-eslint/no-unsafe-member-access */
-/* eslint-disable @typescript-eslint/no-confusing-void-expression */
-/* eslint-disable @typescript-eslint/no-unsafe-return */
-/* eslint-disable @typescript-eslint/no-unsafe-enum-comparison */
-/* eslint-disable @typescript-eslint/no-unnecessary-condition */
-
-import { loadStripe } from "@stripe/stripe-js";
-import { CUSTOM_ERROR } from "utils/error";
-import { logError } from "utils/log";
-import HTTPService from "./HTTPService";
-
-const getStripePublishableKey = (stripeAccount: StripeAccountCountry) => {
- if (stripeAccount === StripeAccountCountry.STRIPE_IN) {
- return (
- process.env.NEXT_PUBLIC_STRIPE_IN_PUBLISHABLE_KEY ??
- "pk_live_51HAhqDK59oeucIMOiTI6MDDM2UWUbCAJXJCGsvjJhiO8nYJz38rQq5T4iyQLDMKxqEDUfU5Hopuj4U5U4dff23oT00fHvZeodC"
- );
- } else if (stripeAccount === StripeAccountCountry.STRIPE_US) {
- return (
- process.env.NEXT_PUBLIC_STRIPE_US_PUBLISHABLE_KEY ??
- "pk_live_51LZ9P4G1ITnQlpAnrP6pcS7NiuJo3SnJ7gibjJlMRatkrd2EY1zlMVTVQG5RkSpLPbsHQzFfnEtgHnk1PiylIFkk00tC0LWXwi"
- );
- } else {
- throw Error("stripe account not found");
- }
-};
-
-const getEndpoint = () => {
- const endPoint =
- process.env.NEXT_PUBLIC_ENTE_ENDPOINT ?? "https://api.ente.io";
- return endPoint;
-};
-enum PAYMENT_INTENT_STATUS {
- SUCCESS = "success",
- REQUIRE_ACTION = "requires_action",
- REQUIRE_PAYMENT_METHOD = "requires_payment_method",
-}
-
-enum FAILURE_REASON {
- // Unable to authenticate card or 3DS
- // User should be showing button for fixing card via customer portal
- AUTHENTICATION_FAILED = "authentication_failed",
- // Card declined result in this error. Show button to the customer portal.
- REQUIRE_PAYMENT_METHOD = "requires_payment_method",
- STRIPE_ERROR = "stripe_error",
- CANCELED = "canceled",
- SERVER_ERROR = "server_error",
-}
-
-enum STRIPE_ERROR_TYPE {
- CARD_ERROR = "card_error",
- AUTHENTICATION_ERROR = "authentication_error",
-}
-
-enum STRIPE_ERROR_CODE {
- AUTHENTICATION_ERROR = "payment_intent_authentication_failure",
-}
-
-enum RESPONSE_STATUS {
- success = "success",
- fail = "fail",
-}
-
-enum PaymentActionType {
- Buy = "buy",
- Update = "update",
-}
-
-enum StripeAccountCountry {
- STRIPE_IN = "IN",
- STRIPE_US = "US",
-}
-
-interface SubscriptionUpdateResponse {
- result: {
- status: PAYMENT_INTENT_STATUS;
- clientSecret: string;
- };
-}
-
-export async function parseAndHandleRequest() {
- try {
- const urlParams = new URLSearchParams(window.location.search);
- const productID = urlParams.get("productID");
- const paymentToken = urlParams.get("paymentToken");
- const action = urlParams.get("action");
- const redirectURL = urlParams.get("redirectURL");
- if (!action && !paymentToken && !productID && !redirectURL) {
- throw Error(CUSTOM_ERROR.DIRECT_OPEN_WITH_NO_QUERY_PARAMS);
- } else if (!action || !paymentToken || !productID || !redirectURL) {
- throw Error(CUSTOM_ERROR.MISSING_REQUIRED_QUERY_PARAM);
- }
- switch (action) {
- case PaymentActionType.Buy:
- await buyPaidSubscription(productID, paymentToken, redirectURL);
- break;
- case PaymentActionType.Update:
- await updateSubscription(productID, paymentToken, redirectURL);
- break;
- default:
- throw Error(CUSTOM_ERROR.INVALID_ACTION);
- }
- } catch (e: any) {
- console.error("Error: ", JSON.stringify(e));
- if (e.message !== CUSTOM_ERROR.DIRECT_OPEN_WITH_NO_QUERY_PARAMS) {
- logError(e);
- }
- throw e;
- }
-}
-
-async function getUserStripeAccountCountry(
- paymentToken: string,
-): Promise<{ stripeAccountCountry: StripeAccountCountry }> {
- const response = await HTTPService.get(
- `${getEndpoint()}/billing/stripe-account-country`,
- undefined,
- {
- "X-Auth-Token": paymentToken,
- },
- );
- return response.data;
-}
-
-async function getStripe(
- redirectURL: string,
- stripeAccount: StripeAccountCountry,
-) {
- try {
- const publishableKey = getStripePublishableKey(stripeAccount);
- const stripe = await loadStripe(publishableKey);
-
- if (!stripe) {
- throw Error("stripe load failed");
- }
- return stripe;
- } catch (e) {
- logError(e, "stripe load failed");
- redirectToApp(
- redirectURL,
- RESPONSE_STATUS.fail,
- FAILURE_REASON.STRIPE_ERROR,
- );
- throw e;
- }
-}
-
-export async function buyPaidSubscription(
- productID: string,
- paymentToken: string,
- redirectURL: string,
-) {
- try {
- const { stripeAccountCountry } =
- await getUserStripeAccountCountry(paymentToken);
- const stripe = await getStripe(redirectURL, stripeAccountCountry);
- const { sessionID } = await createCheckoutSession(
- productID,
- paymentToken,
- redirectURL,
- );
- await stripe.redirectToCheckout({
- sessionId: sessionID,
- });
- } catch (e) {
- logError(e, "subscription purchase failed");
- redirectToApp(
- redirectURL,
- RESPONSE_STATUS.fail,
- FAILURE_REASON.SERVER_ERROR,
- );
- throw e;
- }
-}
-
-async function createCheckoutSession(
- productID: string,
- paymentToken: string,
- redirectURL: string,
-): Promise<{ sessionID: string }> {
- const response = await HTTPService.get(
- `${getEndpoint()}/billing/stripe/checkout-session`,
- {
- productID,
- redirectURL,
- },
- {
- "X-Auth-Token": paymentToken,
- },
- );
- return response.data;
-}
-
-export async function updateSubscription(
- productID: string,
- paymentToken: string,
- redirectURL: string,
-) {
- try {
- const { stripeAccountCountry } =
- await getUserStripeAccountCountry(paymentToken);
- const stripe = await getStripe(redirectURL, stripeAccountCountry);
- const { result } = await subscriptionUpdateRequest(
- paymentToken,
- productID,
- );
- switch (result.status) {
- case PAYMENT_INTENT_STATUS.SUCCESS:
- // subscription updated successfully
- // no-op required
- return redirectToApp(redirectURL, RESPONSE_STATUS.success);
-
- case PAYMENT_INTENT_STATUS.REQUIRE_PAYMENT_METHOD:
- return redirectToApp(
- redirectURL,
- RESPONSE_STATUS.fail,
- FAILURE_REASON.REQUIRE_PAYMENT_METHOD,
- );
- case PAYMENT_INTENT_STATUS.REQUIRE_ACTION: {
- const { error } = await stripe.confirmCardPayment(
- result.clientSecret,
- );
- if (error) {
- logError(
- error,
- `${error.message} - subscription update failed`,
- );
- if (error.type === STRIPE_ERROR_TYPE.CARD_ERROR) {
- return redirectToApp(
- redirectURL,
- RESPONSE_STATUS.fail,
- FAILURE_REASON.REQUIRE_PAYMENT_METHOD,
- );
- } else if (
- error.type === STRIPE_ERROR_TYPE.AUTHENTICATION_ERROR ||
- error.code === STRIPE_ERROR_CODE.AUTHENTICATION_ERROR
- ) {
- return redirectToApp(
- redirectURL,
- RESPONSE_STATUS.fail,
- FAILURE_REASON.AUTHENTICATION_FAILED,
- );
- } else {
- return redirectToApp(redirectURL, RESPONSE_STATUS.fail);
- }
- } else {
- return redirectToApp(redirectURL, RESPONSE_STATUS.success);
- }
- }
- }
- } catch (e) {
- logError(e, "subscription update failed");
- redirectToApp(
- redirectURL,
- RESPONSE_STATUS.fail,
- FAILURE_REASON.SERVER_ERROR,
- );
- throw e;
- }
-}
-
-async function subscriptionUpdateRequest(
- paymentToken: string,
- productID: string,
-): Promise {
- const response = await HTTPService.post(
- `${getEndpoint()}/billing/stripe/update-subscription`,
- {
- productID,
- },
- undefined,
- {
- "X-Auth-Token": paymentToken,
- },
- );
- return response.data;
-}
-
-function redirectToApp(redirectURL: string, status: string, reason?: string) {
- let completePath = `${redirectURL}?status=${status}`;
- if (reason) {
- completePath = `${completePath}&reason=${reason}`;
- }
- window.location.href = completePath;
-}
diff --git a/web/apps/payments/src/styles/globals.css b/web/apps/payments/src/styles/globals.css
index c1a7f539d079343e0a789774e7fec76475ab6b75..bd4bd106bdd0f969fb685a71db8116a613fe71e0 100644
--- a/web/apps/payments/src/styles/globals.css
+++ b/web/apps/payments/src/styles/globals.css
@@ -1,32 +1,15 @@
-html,
body {
- padding: 0;
- margin: 0;
font-family: system-ui, sans-serif;
- height: 100%;
- flex: 1;
- display: flex;
- flex-direction: column;
- background-color: #191919 !important;
- color: #aaa !important;
-}
-
-:is(h1, h2, h3, h4, h5, h6) {
- color: #d7d7d7;
-}
-
-#__next {
- flex: 1;
- display: flex;
- flex-direction: column;
+ background-color: #191919;
+ color: #aaa;
}
.container {
display: flex;
- flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
+ min-height: 100svh;
}
.loading-spinner {
diff --git a/web/apps/payments/src/utils/error.ts b/web/apps/payments/src/utils/error.ts
deleted file mode 100644
index c38b2bf6cd1560256b0dd4fb7a53ba49265fbbfb..0000000000000000000000000000000000000000
--- a/web/apps/payments/src/utils/error.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export const CUSTOM_ERROR = {
- DIRECT_OPEN_WITH_NO_QUERY_PARAMS: "direct open with no query params",
- MISSING_REQUIRED_QUERY_PARAM: "missing required query param",
- INVALID_ACTION: "invalid action",
-};
diff --git a/web/apps/payments/src/utils/log.ts b/web/apps/payments/src/utils/log.ts
deleted file mode 100644
index 5a2640113556625098b180cb9f3aca4abc1da201..0000000000000000000000000000000000000000
--- a/web/apps/payments/src/utils/log.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const logError = (e: unknown, msg?: string) => {
- console.error(msg, e);
-};
diff --git a/web/apps/payments/src/utils/strings.ts b/web/apps/payments/src/utils/strings.ts
index 44ba64b8feb3847544f88a9399d4edcb6b3b0a50..5632289bee89ae5fd0ef164ba8d9bea8d1383ae5 100644
--- a/web/apps/payments/src/utils/strings.ts
+++ b/web/apps/payments/src/utils/strings.ts
@@ -1,7 +1,14 @@
-const englishConstants = {
- TITLE: "Payments | ente.io",
- SOMETHING_WENT_WRONG: "Oops, something went wrong.",
- NOT_FOUND: "404 | This page could not be found.",
+/**
+ * User facing strings in the app
+ *
+ * By keeping them separate, we make our lives easier if/when we need to
+ * localize the corresponding pages. Right now, these are just the values in the
+ * default language, English.
+ */
+const S = {
+ title: "Payments | ente.io",
+ error_generic: "Oops, something went wrong.",
+ error_404: "404 | This page could not be found.",
};
-export default englishConstants;
+export default S;
diff --git a/web/apps/payments/tsconfig.json b/web/apps/payments/tsconfig.json
index d21df2bc4735bfa5fa2060079645ed129a3bb3fe..f40d4ddd7b7aa6c4a9b42f56af31d27901f2cbe1 100644
--- a/web/apps/payments/tsconfig.json
+++ b/web/apps/payments/tsconfig.json
@@ -11,7 +11,7 @@
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "react",
+ "jsx": "preserve",
"baseUrl": "./src",
"incremental": true,
"allowJs": true
diff --git a/web/docs/dependencies.md b/web/docs/dependencies.md
index 0a7e2b38d5b462d65d2c748501d94a5c48b506e2..5be71bd3e17ccdadcc236c256f60d94cbb88f3ce 100644
--- a/web/docs/dependencies.md
+++ b/web/docs/dependencies.md
@@ -5,21 +5,21 @@
These are some global dev dependencies in the root `package.json`. These set the
baseline for how our code be in all the workspaces in this (yarn) monorepo.
-* "prettier" - Formatter
-* "eslint" - Linter
-* "typescript" - Type checker
+- "prettier" - Formatter
+- "eslint" - Linter
+- "typescript" - Type checker
They also need some support packages, which come from the leaf `@/build-config`
package:
-* "@typescript-eslint/parser" - Tells ESLint how to read TypeScript syntax
-* "@typescript-eslint/eslint-plugin" - Provides TypeScript rules and presets
-* "eslint-plugin-react-hooks", "eslint-plugin-react-namespace-import" - Some
- React specific ESLint rules and configurations that are used by the workspaces
- that have React code.
-* "prettier-plugin-organize-imports" - A Prettier plugin to sort imports.
-* "prettier-plugin-packagejson" - A Prettier plugin to also prettify
- `package.json`.
+- "@typescript-eslint/parser" - Tells ESLint how to read TypeScript syntax
+- "@typescript-eslint/eslint-plugin" - Provides TypeScript rules and presets
+- "eslint-plugin-react-hooks", "eslint-plugin-react-namespace-import" - Some
+ React specific ESLint rules and configurations that are used by the
+ workspaces that have React code.
+- "prettier-plugin-organize-imports" - A Prettier plugin to sort imports.
+- "prettier-plugin-packagejson" - A Prettier plugin to also prettify
+ `package.json`.
## Utils
@@ -31,11 +31,11 @@ Emscripten, maintained by the original authors of libsodium themselves -
[libsodium-wrappers](https://github.com/jedisct1/libsodium.js).
Currently, we've pinned the version to 0.7.9 since later versions remove the
-crypto_pwhash_* functionality that we use (they've not been deprecated, they've
-just been moved to a different NPM package). From the (upstream) [release
-notes](https://github.com/jedisct1/libsodium/releases/tag/1.0.19-RELEASE):
+`crypto_pwhash_*` functionality that we use (they've not been deprecated,
+they've just been moved to a different NPM package). From the (upstream)
+[release notes](https://github.com/jedisct1/libsodium/releases/tag/1.0.19-RELEASE):
-> Emscripten: the crypto_pwhash_*() functions have been removed from Sumo
+> Emscripten: the `crypto_pwhash_*()` functions have been removed from Sumo
> builds, as they reserve a substantial amount of JavaScript memory, even when
> not used.
@@ -62,18 +62,18 @@ preferred CSS-in-JS library.
Emotion itself comes in many parts, of which we need the following:
-* "@emotion/react" - React interface to Emotion. In particular, we set this as
- the package that handles the transformation of JSX into JS (via the
- `jsxImportSource` property in `tsconfig.json`).
+- "@emotion/react" - React interface to Emotion. In particular, we set this as
+ the package that handles the transformation of JSX into JS (via the
+ `jsxImportSource` property in `tsconfig.json`).
-* "@emotion/styled" - Provides the `styled` utility, a la styled-components. We
- don't use it directly, instead we import it from `@mui/material`. However, MUI
- docs
- [mention](https://mui.com/material-ui/integrations/interoperability/#styled-components)
- that
+- "@emotion/styled" - Provides the `styled` utility, a la styled-components.
+ We don't use it directly, instead we import it from `@mui/material`.
+ However, MUI docs
+ [mention](https://mui.com/material-ui/integrations/interoperability/#styled-components)
+ that
- > Keep `@emotion/styled` as a dependency of your project. Even if you never
- > use it explicitly, it's a peer dependency of `@mui/material`.
+ > Keep `@emotion/styled` as a dependency of your project. Even if you never
+ > use it explicitly, it's a peer dependency of `@mui/material`.
#### Component selectors
@@ -87,8 +87,8 @@ selectors API (i.e using `styled.div` instead of `styled("div")`).
There is a way of enabling it by installing the `@emotion/babel-plugin` and
specifying the import map as mentioned
-[here](https://mui.com/system/styled/#how-to-use-components-selector-api) ([full
-example](https://github.com/mui/material-ui/issues/27380#issuecomment-928973157)),
+[here](https://mui.com/system/styled/#how-to-use-components-selector-api)
+([full example](https://github.com/mui/material-ui/issues/27380#issuecomment-928973157)),
but that disables the SWC integration altogether, so we live with this
infelicity for now.
@@ -97,10 +97,11 @@ infelicity for now.
For showing the app's UI in multiple languages, we use the i18next library,
specifically its three components
-* "i18next": The core `i18next` library.
-* "i18next-http-backend": Adds support for initializing `i18next` with JSON file
- containing the translation in a particular language, fetched at runtime.
-* "react-i18next": React specific support in `i18next`.
+- "i18next": The core `i18next` library.
+- "i18next-http-backend": Adds support for initializing `i18next` with JSON
+ file containing the translation in a particular language, fetched at
+ runtime.
+- "react-i18next": React specific support in `i18next`.
Note that inspite of the "next" in the name of the library, it has nothing to do
with Next.js.
@@ -120,4 +121,3 @@ set of defaults for bundling our app into a static export which we can then
deploy to our webserver. In addition, the Next.js page router is convenient.
Apart from this, while we use a few tidbits from Next.js here and there, overall
our apps are regular React SPAs, and are not particularly tied to Next.
-
diff --git a/web/docs/deploy.md b/web/docs/deploy.md
index 2c3cfcd97a10ac36aa13161649d47f93de08f0e8..6b51a0199d2bf77d506fb8ffb4403a1d766522ff 100644
--- a/web/docs/deploy.md
+++ b/web/docs/deploy.md
@@ -3,17 +3,17 @@
The various web apps and static sites in this repository are deployed on
Cloudflare Pages.
-* Production deployments are triggered by pushing to the `deploy/*` branches.
+- Production deployments are triggered by pushing to the `deploy/*` branches.
-* [help.ente.io](https://help.ente.io) gets deployed whenever a PR that changes
- anything inside `docs/` gets merged to `main`.
+- [help.ente.io](https://help.ente.io) gets deployed whenever a PR that
+ changes anything inside `docs/` gets merged to `main`.
-* Every night, all the web apps get automatically deployed to a nightly preview
- URLs (`*.ente.sh`) using the current code in main.
+- Every night, all the web apps get automatically deployed to a nightly
+ preview URLs (`*.ente.sh`) using the current code in main.
-* A preview deployment can be made by triggering the "Preview (web)" workflow.
- This allows us to deploy a build of any of the apps from an arbitrary branch
- to [preview.ente.sh](https://preview.ente.sh).
+- A preview deployment can be made by triggering the "Preview (web)" workflow.
+ This allows us to deploy a build of any of the apps from an arbitrary branch
+ to [preview.ente.sh](https://preview.ente.sh).
Use the various `yarn deploy:*` commands to help with production deployments.
For example, `yarn deploy:photos` will open a PR to merge the current `main`
@@ -29,33 +29,33 @@ and publish to [web.ente.io](https://web.ente.io).
Here is a list of all the deployments, whether or not they are production
deployments, and the action that triggers them:
-| URL | Type |Deployment action |
-|-----|------|------------------|
-| [web.ente.io](https://web.ente.io) | Production | Push to `deploy/photos` |
-| [photos.ente.io](https://photos.ente.io) | Production | Alias of [web.ente.io](https://web.ente.io) |
-| [auth.ente.io](https://auth.ente.io) | Production | Push to `deploy/auth` |
-| [accounts.ente.io](https://accounts.ente.io) | Production | Push to `deploy/accounts` |
-| [cast.ente.io](https://cast.ente.io) | Production | Push to `deploy/cast` |
-| [payments.ente.io](https://payments.ente.io) | Production | Push to `deploy/payments` |
-| [help.ente.io](https://help.ente.io) | Production | Push to `main` + changes in `docs/` |
-| [accounts.ente.sh](https://accounts.ente.sh) | Preview | Nightly deploy of `main` |
-| [auth.ente.sh](https://auth.ente.sh) | Preview | Nightly deploy of `main` |
-| [cast.ente.sh](https://cast.ente.sh) | Preview | Nightly deploy of `main` |
-| [payments.ente.sh](https://payments.ente.sh) | Preview | Nightly deploy of `main` |
-| [photos.ente.sh](https://photos.ente.sh) | Preview | Nightly deploy of `main` |
-| [preview.ente.sh](https://preview.ente.sh) | Preview | Manually triggered |
+| URL | Type | Deployment action |
+| -------------------------------------------- | ---------- | ------------------------------------------- |
+| [web.ente.io](https://web.ente.io) | Production | Push to `deploy/photos` |
+| [photos.ente.io](https://photos.ente.io) | Production | Alias of [web.ente.io](https://web.ente.io) |
+| [auth.ente.io](https://auth.ente.io) | Production | Push to `deploy/auth` |
+| [accounts.ente.io](https://accounts.ente.io) | Production | Push to `deploy/accounts` |
+| [cast.ente.io](https://cast.ente.io) | Production | Push to `deploy/cast` |
+| [payments.ente.io](https://payments.ente.io) | Production | Push to `deploy/payments` |
+| [help.ente.io](https://help.ente.io) | Production | Push to `main` + changes in `docs/` |
+| [accounts.ente.sh](https://accounts.ente.sh) | Preview | Nightly deploy of `main` |
+| [auth.ente.sh](https://auth.ente.sh) | Preview | Nightly deploy of `main` |
+| [cast.ente.sh](https://cast.ente.sh) | Preview | Nightly deploy of `main` |
+| [payments.ente.sh](https://payments.ente.sh) | Preview | Nightly deploy of `main` |
+| [photos.ente.sh](https://photos.ente.sh) | Preview | Nightly deploy of `main` |
+| [preview.ente.sh](https://preview.ente.sh) | Preview | Manually triggered |
### Other subdomains
Apart from this, there are also some other deployments:
-- `albums.ente.io` is a CNAME alias to the production deployment
- (`web.ente.io`). However, when the code detects that it is being served from
- `albums.ente.io`, it redirects to the `/shared-albums` page (Enhancement:
- serve it as a separate app with a smaller bundle size).
+- `albums.ente.io` is a CNAME alias to the production deployment
+ (`web.ente.io`). However, when the code detects that it is being served from
+ `albums.ente.io`, it redirects to the `/shared-albums` page (Enhancement:
+ serve it as a separate app with a smaller bundle size).
-- `family.ente.io` is currently in a separate repositories (Enhancement: bring
- them in here).
+- `family.ente.io` is currently in a separate repositories (Enhancement: bring
+ them in here).
### Preview deployments
@@ -79,21 +79,20 @@ likely don't need to know them to be able to deploy.
## First time preparation
-Create a new Pages project in Cloudflare, setting it up to use [Direct
-Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/).
+Create a new Pages project in Cloudflare, setting it up to use
+[Direct Upload](https://developers.cloudflare.com/pages/get-started/direct-upload/).
> [!NOTE]
>
> Direct upload doesn't work for existing projects tied to your repository using
-> the [Git
-> integration](https://developers.cloudflare.com/pages/get-started/git-integration/).
+> the
+> [Git integration](https://developers.cloudflare.com/pages/get-started/git-integration/).
>
> If you want to keep the pages.dev domain from an existing project, you should
> be able to delete your existing project and recreate it (assuming no one
> claims the domain in the middle). I've not seen this documented anywhere, but
-> it worked when I tried, and it seems to have worked for [other people
-> too](https://community.cloudflare.com/t/linking-git-repo-to-existing-cf-pages-project/530888).
-
+> it worked when I tried, and it seems to have worked for
+> [other people too](https://community.cloudflare.com/t/linking-git-repo-to-existing-cf-pages-project/530888).
There are two ways to create a new project, using Wrangler
[[1](https://github.com/cloudflare/pages-action/issues/51)] or using the
@@ -101,14 +100,13 @@ Cloudflare dashboard
[[2](https://github.com/cloudflare/pages-action/issues/115)]. Since this is one
time thing, the second option might be easier.
-The remaining steps are documented in [Cloudflare's guide for using Direct
-Upload with
-CI](https://developers.cloudflare.com/pages/how-to/use-direct-upload-with-continuous-integration/).
+The remaining steps are documented in
+[Cloudflare's guide for using Direct Upload with CI](https://developers.cloudflare.com/pages/how-to/use-direct-upload-with-continuous-integration/).
As a checklist,
-- Generate `CLOUDFLARE_API_TOKEN`
-- Add `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` to the GitHub secrets
-- Add your workflow. e.g. see `docs-deploy.yml`.
+- Generate `CLOUDFLARE_API_TOKEN`
+- Add `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_API_TOKEN` to the GitHub secrets
+- Add your workflow. e.g. see `docs-deploy.yml`.
This is the basic setup, and should already work.
@@ -125,12 +123,11 @@ the `branch` parameter that gets passed to `cloudflare/pages-action`.
Since our root pages project is `ente.pages.dev`, so a branch named `foo` would
be available at `foo.ente.pages.dev`.
-Finally, we create CNAME aliases using a [Custom Domain in
-Cloudflare](https://developers.cloudflare.com/pages/how-to/custom-branch-aliases/)
+Finally, we create CNAME aliases using a
+[Custom Domain in Cloudflare](https://developers.cloudflare.com/pages/how-to/custom-branch-aliases/)
to point to these deployments from our user facing DNS names.
As a concrete example, the GitHub workflow that deploys `docs/` passes "help" as
the branch name. The resulting deployment is available at "help.ente.pages.dev".
Finally, we add a custom domain to point to it from
[help.ente.io](https://help.ente.io).
-
diff --git a/web/docs/dev.md b/web/docs/dev.md
index 0b980c1e2836430d77d070f32db6dc9b6f837473..953aa005e41db32158e022c38ce5c39be601b797 100644
--- a/web/docs/dev.md
+++ b/web/docs/dev.md
@@ -4,8 +4,8 @@
We recommend VS Code, with the following extensions:
-- Prettier - reformats your code automatically (enable format on save),
-- ESLint - warns you about issues
+- Prettier - reformats your code automatically (enable format on save),
+- ESLint - warns you about issues
Optionally, if you're going to make many changes to the CSS in JS, you might
also find it useful to install the _vscode-styled-components_ extension.
@@ -19,14 +19,14 @@ Make sure you're on yarn 1.x series (aka yarn "classic").
Installs dependencies. This needs to be done once, and thereafter wherever there
is a change in `yarn.lock` (e.g. when pulling the latest upstream).
-### yarn dev:*
+### yarn dev:\*
Launch the app in development mode. There is one `yarn dev:foo` for each app,
e.g. `yarn dev:auth`. `yarn dev` is a shortcut for `yarn dev:photos`.
The ports are different for the main apps (3000), various sidecars (3001, 3002).
-### yarn build:*
+### yarn build:\*
Build a production export for the app. This is a bunch of static HTML/JS/CSS
that can be then deployed to any web server.
@@ -34,7 +34,7 @@ that can be then deployed to any web server.
There is one `yarn build:foo` for each app, e.g. `yarn build:auth`. The output
will be placed in `apps//out`, e.g. `apps/auth/out`.
-### yarn preview:*
+### yarn preview:\*
Build a production export and start a local web server to serve it. This uses
Python's built in web server, and is okay for quick testing but should not be
@@ -53,8 +53,8 @@ issues.
The monorepo uses Yarn (classic) workspaces.
To run a command for a workspace ``, invoke `yarn workspace ` from
-the root folder instead the `yarn ` you’d have done otherwise. For
-example, to start a development server for the `photos` app, we can do
+the root folder instead the `yarn ` you’d have done otherwise. For example,
+to start a development server for the `photos` app, we can do
```sh
yarn workspace photos next dev
@@ -74,7 +74,7 @@ by running `yarn install` first.
> `yarn` is a shortcut for `yarn install`
-To add a local package as a dependency, use `@*`. The "*" here
+To add a local package as a dependency, use `@*`. The "\*" here
denotes any version.
```sh
diff --git a/web/docs/storage.md b/web/docs/storage.md
new file mode 100644
index 0000000000000000000000000000000000000000..8f072684bfe28681417413087e8657987e3152de
--- /dev/null
+++ b/web/docs/storage.md
@@ -0,0 +1,16 @@
+# Storage
+
+## Local Storage
+
+Data in the local storage is persisted even after the user closes the tab (or
+the browser itself). This is in contrast with session storage, where the data is
+cleared when the browser tab is closed.
+
+The data in local storage is tied to the Document's origin (scheme + host).
+
+## Session Storage
+
+## Indexed DB
+
+We use the LocalForage library for storing things in Indexed DB. This library
+falls back to localStorage in case Indexed DB storage is not available.
diff --git a/web/docs/translations.md b/web/docs/translations.md
index fcf838e25b009efe8715cb3a05d444d72e061d22..f06ab7c6c4d476ab4ce419a9a43c08fd591d7429 100644
--- a/web/docs/translations.md
+++ b/web/docs/translations.md
@@ -7,39 +7,40 @@ Within our project we have the _source_ strings - these are the key value pairs
in the `public/locales/en-US/translation.json` file in each app.
Volunteers can add a new _translation_ in their language corresponding to each
-such source key-value to our [Crowdin
-project](https://crowdin.com/project/ente-photos-web).
+such source key-value to our
+[Crowdin project](https://crowdin.com/project/ente-photos-web).
Everyday, we run a [GitHub workflow](../../.github/workflows/web-crowdin.yml)
that
-* Uploads sources to Crowdin - So any new key value pair we add in the source
- `translation.json` becomes available to translators to translate.
+- Uploads sources to Crowdin - So any new key value pair we add in the source
+ `translation.json` becomes available to translators to translate.
-* Downloads translations from Crowdin - So any new translations that translators
- have made on the Crowdin dashboard (for existing sources) will be added to the
- corresponding `lang/translation.json`.
+- Downloads translations from Crowdin - So any new translations that
+ translators have made on the Crowdin dashboard (for existing sources) will
+ be added to the corresponding `lang/translation.json`.
The workflow also uploads existing translations and also downloads new sources
from Crowdin, but these two should be no-ops.
## Adding a new string
-- Add a new entry in `public/locales/en-US/translation.json` (the **source `translation.json`**).
-- Use the new key in code with the `t` function (`import { t } from "i18next"`).
-- During the next sync, the workflow will upload this source item to Crowdin's
- dashboard, allowing translators to translate it.
+- Add a new entry in `public/locales/en-US/translation.json` (the **source
+ `translation.json`**).
+- Use the new key in code with the `t` function
+ (`import { t } from "i18next"`).
+- During the next sync, the workflow will upload this source item to Crowdin's
+ dashboard, allowing translators to translate it.
## Updating an existing string
-- Update the existing value for the key in the source `translation.json`.
-- During the next sync, the workflow will clear out all the existing
- translations so that they can be translated afresh.
+- Update the existing value for the key in the source `translation.json`.
+- During the next sync, the workflow will clear out all the existing
+ translations so that they can be translated afresh.
## Deleting an existing string
-- Remove the key value pair from the source `translation.json`.
-- During the next sync, the workflow will delete that source item from all
- existing translations (both in the Crowdin project and also from the he other
- `lang/translation.json` files in the repository).
-
+- Remove the key value pair from the source `translation.json`.
+- During the next sync, the workflow will delete that source item from all
+ existing translations (both in the Crowdin project and also from the he
+ other `lang/translation.json` files in the repository).
diff --git a/web/docs/webauthn-passkeys.md b/web/docs/webauthn-passkeys.md
index 91fc23f30e6fca21f5f0bccad85137f832f0616c..d521dc0ede8ce9172b1a3c750ecb12ec14545626 100644
--- a/web/docs/webauthn-passkeys.md
+++ b/web/docs/webauthn-passkeys.md
@@ -1,6 +1,14 @@
# Passkeys on Ente
-Passkeys is a colloquial term for a relatively new authentication standard called [WebAuthn](https://en.wikipedia.org/wiki/WebAuthn). Now rolled out to all major browsers and operating systems, it uses asymmetric cryptography to authenticate the user with a server using replay-attack resistant signatures. These processes are usually abstracted from the user through biometric prompts, such as Touch ID/Face ID/Optic ID, Fingerprint Unlock and Windows Hello. These passkeys can also be securely synced by major password managers, such as Bitwarden and 1Password, although the syncing experience can greatly vary due to some operating system restrictions.
+Passkeys is a colloquial term for a relatively new authentication standard
+called [WebAuthn](https://en.wikipedia.org/wiki/WebAuthn). Now rolled out to all
+major browsers and operating systems, it uses asymmetric cryptography to
+authenticate the user with a server using replay-attack resistant signatures.
+These processes are usually abstracted from the user through biometric prompts,
+such as Touch ID/Face ID/Optic ID, Fingerprint Unlock and Windows Hello. These
+passkeys can also be securely synced by major password managers, such as
+Bitwarden and 1Password, although the syncing experience can greatly vary due to
+some operating system restrictions.
## Terms
@@ -13,13 +21,20 @@ Passkeys is a colloquial term for a relatively new authentication standard calle
## Getting to the passkeys manager
-As of Feb 2024, Ente clients have a button to navigate to a WebView of Ente Accounts. Ente Accounts allows users to add and manage their registered passkeys.
+As of Feb 2024, Ente clients have a button to navigate to a WebView of Ente
+Accounts. Ente Accounts allows users to add and manage their registered
+passkeys.
-❗ Your WebView MUST invoke the operating-system's default browser, or an equivalent browser with matching API parity. Otherwise, the user will not be able to register or use registered WebAuthn credentials.
+❗ Your WebView MUST invoke the operating-system's default browser, or an
+equivalent browser with matching API parity. Otherwise, the user will not be
+able to register or use registered WebAuthn credentials.
### Accounts-Specific Session Token
-When a user clicks this button, the client sends a request for an Accounts-specific JWT session token as shown below. **The Ente Accounts API is restricted to this type of session token, so the user session token cannot be used.** This restriction is a byproduct of the enablement for automatic login.
+When a user clicks this button, the client sends a request for an
+Accounts-specific JWT session token as shown below. **The Ente Accounts API is
+restricted to this type of session token, so the user session token cannot be
+used.** This restriction is a byproduct of the enablement for automatic login.
#### GET /users/accounts-token
@@ -37,17 +52,33 @@ When a user clicks this button, the client sends a request for an Accounts-speci
### Automatically logging into Accounts
-Clients open a WebView with the URL `https://accounts.ente.io/accounts-handoff?token=&package=`. This page will appear like a normal loading screen to the user, but in the background, the app parses the token and package for usage in subsequent Accounts-related API calls.
+Clients open a WebView with the URL
+`https://accounts.ente.io/accounts-handoff?token=&package=`.
+This page will appear like a normal loading screen to the user, but in the
+background, the app parses the token and package for usage in subsequent
+Accounts-related API calls.
-If valid, the user will be automatically redirected to the passkeys management page. Otherwise, they will be required to login with their Ente credentials.
+If valid, the user will be automatically redirected to the passkeys management
+page. Otherwise, they will be required to login with their Ente credentials.
## Registering a WebAuthn credential
### Requesting publicKey options (begin)
-The registration ceremony starts in the browser. When the user clicks the "Add new passkey" button, a request is sent to the server for "public key" creation options. Although named "public key" options, they actually define customizable parameters for the entire credential creation process. They're like an instructional sheet that defines exactly what we want. As of the creation of this document, the plan is to restrict user authenticators to cross-platform ones, like hardware keys. Platform authenticators, such as TPM, are not portable and are prone to loss.
-
-On the server side, the WebAuthn library generates this information based on data provided from a `webauthn.User` interface. As a result, we satisfy this interface by creating a type with methods returning information from the database. Information stored in the database about credentials are all pre-processed using base64 where necessary.
+The registration ceremony starts in the browser. When the user clicks the "Add
+new passkey" button, a request is sent to the server for "public key" creation
+options. Although named "public key" options, they actually define customizable
+parameters for the entire credential creation process. They're like an
+instructional sheet that defines exactly what we want. As of the creation of
+this document, the plan is to restrict user authenticators to cross-platform
+ones, like hardware keys. Platform authenticators, such as TPM, are not portable
+and are prone to loss.
+
+On the server side, the WebAuthn library generates this information based on
+data provided from a `webauthn.User` interface. As a result, we satisfy this
+interface by creating a type with methods returning information from the
+database. Information stored in the database about credentials are all
+pre-processed using base64 where necessary.
```go
type PasskeyUser struct {
@@ -162,28 +193,26 @@ func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential {
### Pre-processing the options before registration
-Even though the server generates these options, the browser still doesn't understand them. For interoperability, the server's WebAuthn library returns binary data in base64, like IDs and the challenge. However, the browser requires this data back in binary.
+Even though the server generates these options, the browser still doesn't
+understand them. For interoperability, the server's WebAuthn library returns
+binary data in base64, like IDs and the challenge. However, the browser requires
+this data back in binary.
We just have to decode the base64 fields back into `Uint8Array`.
```ts
const options = response.options;
-options.publicKey.challenge = _sodium.from_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- options.publicKey.challenge,
-);
-options.publicKey.user.id = _sodium.from_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
- options.publicKey.user.id,
-);
+options.publicKey.challenge = _sodium.from_base64(options.publicKey.challenge);
+options.publicKey.user.id = _sodium.from_base64(options.publicKey.user.id);
```
### Creating the credential
-We use `navigator.credentials.create` with these options to generate the credential. At this point, the user will see a prompt to decide where to save this credential, and probably a biometric authentication gate depending on the platform.
+We use `navigator.credentials.create` with these options to generate the
+credential. At this point, the user will see a prompt to decide where to save
+this credential, and probably a biometric authentication gate depending on the
+platform.
```ts
const newCredential = await navigator.credentials.create(options);
@@ -191,29 +220,31 @@ const newCredential = await navigator.credentials.create(options);
### Sending the public key to the server (finish)
-The browser returns the newly created credential with a bunch of binary fields, so we have to encode them into base64 for transport to the server.
+The browser returns the newly created credential with a bunch of binary fields,
+so we have to encode them into base64 for transport to the server.
```ts
const attestationObjectB64 = _sodium.to_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
new Uint8Array(credential.response.attestationObject),
_sodium.base64_variants.URLSAFE_NO_PADDING
);
const clientDataJSONB64 = _sodium.to_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
new Uint8Array(credential.response.clientDataJSON),
_sodium.base64_variants.URLSAFE_NO_PADDING
```
-Attestation object contains information about the nature of the credential, like what device it was generated on. Client data JSON contains metadata about the credential, like where it is registered to.
+Attestation object contains information about the nature of the credential, like
+what device it was generated on. Client data JSON contains metadata about the
+credential, like where it is registered to.
-After pre-processing, the client sends the public key to the server so it can verify future signatures during authentication.
+After pre-processing, the client sends the public key to the server so it can
+verify future signatures during authentication.
#### POST /passkeys/registration/finish
-When the server receives the new public key credential, it pre-processes the JSON objects so they can fit within the database. This includes base64 encoding `[]byte` slices and their encompassing arrays or objects.
+When the server receives the new public key credential, it pre-processes the
+JSON objects so they can fit within the database. This includes base64 encoding
+`[]byte` slices and their encompassing arrays or objects.
```go
// Convert the PublicKey to base64
@@ -288,7 +319,11 @@ On retrieval, this process is effectively the opposite.
## Authenticating with a credential
-Passkeys have been integrated into the existing two-factor ceremony. When logging in via SRP or verifying an email OTT, the server checks if the user has any number of credentials setup or has 2FA TOTP enabled. If the user has setup at least one credential, they will be served a `passkeySessionID` which will initiate the authentication ceremony.
+Passkeys have been integrated into the existing two-factor ceremony. When
+logging in via SRP or verifying an email OTT, the server checks if the user has
+any number of credentials setup or has 2FA TOTP enabled. If the user has setup
+at least one credential, they will be served a `passkeySessionID` which will
+initiate the authentication ceremony.
```tsx
const {
@@ -302,7 +337,9 @@ if (passkeySessionID) {
}
```
-The client should redirect the user to Accounts with this session ID to prompt credential authentication. We use Accounts as the central WebAuthn hub because credentials are locked to an FQDN.
+The client should redirect the user to Accounts with this session ID to prompt
+credential authentication. We use Accounts as the central WebAuthn hub because
+credentials are locked to an FQDN.
```tsx
window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${passkeySessionID}&redirect=${
@@ -352,7 +389,8 @@ window.location.href = `${getAccountsURL()}/passkeys/flow?passkeySessionID=${pas
### Pre-processing the options before retrieval
-The browser requires `Uint8Array` versions of the `options` challenge and credential IDs.
+The browser requires `Uint8Array` versions of the `options` challenge and
+credential IDs.
```ts
publicKey.challenge = _sodium.from_base64(
@@ -377,30 +415,23 @@ const credential = await navigator.credentials.get({
### Pre-processing the credential metadata and signature before authentication
-Before sending the public key and signature to the server, their outputs must be encoded into Base64.
+Before sending the public key and signature to the server, their outputs must be
+encoded into Base64.
```ts
authenticatorData: _sodium.to_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
new Uint8Array(credential.response.authenticatorData),
_sodium.base64_variants.URLSAFE_NO_PADDING
),
clientDataJSON: _sodium.to_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
new Uint8Array(credential.response.clientDataJSON),
_sodium.base64_variants.URLSAFE_NO_PADDING
),
signature: _sodium.to_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
new Uint8Array(credential.response.signature),
_sodium.base64_variants.URLSAFE_NO_PADDING
),
userHandle: _sodium.to_base64(
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore
new Uint8Array(credential.response.userHandle),
_sodium.base64_variants.URLSAFE_NO_PADDING
),
diff --git a/web/packages/build-config/README.md b/web/packages/build-config/README.md
index 8e62b1b3d20b67ad6a7c514a7b958246bb19fb55..249bf691599c4784793861297c6fc60ec35bfbdd 100644
--- a/web/packages/build-config/README.md
+++ b/web/packages/build-config/README.md
@@ -13,8 +13,8 @@ transpiled, it just exports static files that can be included verbatim.
Too see what tsc is seeing (say when it is trying to type-check `@/utils`), use
`yarn workspace @/utils tsc --showConfig`.
-Similarly, to verify what ESLint is trying to do, use `yarn workspace @/utils
-eslint --debug .`
+Similarly, to verify what ESLint is trying to do, use
+`yarn workspace @/utils eslint --debug .`
If the issue is in VSCode, open the output window of the corresponding plugin,
it might be telling us what's going wrong there. In particular, when changing
diff --git a/web/packages/build-config/eslintrc-base.js b/web/packages/build-config/eslintrc-base.js
index 32e4357b79ffafa11435cffe55150d03ffe5aab9..b302be36d4cba355cfaddaee5a1be7e6c284dbf0 100644
--- a/web/packages/build-config/eslintrc-base.js
+++ b/web/packages/build-config/eslintrc-base.js
@@ -1,18 +1,13 @@
/* eslint-env node */
module.exports = {
+ root: true,
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/strict-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
],
plugins: ["@typescript-eslint"],
+ parserOptions: { project: true },
parser: "@typescript-eslint/parser",
- parserOptions: {
- project: true,
- },
- settings: {
- react: { version: "18.2" },
- },
- root: true,
ignorePatterns: [".eslintrc.js"],
};
diff --git a/web/packages/build-config/eslintrc-react.js b/web/packages/build-config/eslintrc-react.js
index adaf8daba49500644cbdd817ed260e5ef6d8ac87..13df31cfd1440688672c4f9b4937a78c7f46fc64 100644
--- a/web/packages/build-config/eslintrc-react.js
+++ b/web/packages/build-config/eslintrc-react.js
@@ -5,4 +5,5 @@ module.exports = {
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
+ settings: { react: { version: "18.2" } },
};
diff --git a/web/packages/build-config/tsconfig.transpile.json b/web/packages/build-config/tsconfig.transpile.json
index c9725e8e468e8d03ff9ccaa2e2f612df10561340..3040fc05761344b4280e7737002afaf85cf13a84 100644
--- a/web/packages/build-config/tsconfig.transpile.json
+++ b/web/packages/build-config/tsconfig.transpile.json
@@ -1,6 +1,5 @@
{
/* TSConfig for a TypeScript project that'll get transpiled by Next.js */
-
/* TSConfig docs: https://aka.ms/tsconfig.json */
"compilerOptions": {
/* We use TypeScript (tsc) as a type checker, not as a build tool */
diff --git a/web/packages/next/.eslintrc.js b/web/packages/next/.eslintrc.js
index e032427b07ffaa39d27fb9d185463bd2b34b8813..4a4e4d15dc839611605f9f93dbcfbf6ca1f47d24 100644
--- a/web/packages/next/.eslintrc.js
+++ b/web/packages/next/.eslintrc.js
@@ -1,8 +1,4 @@
module.exports = {
extends: ["@/build-config/eslintrc-next"],
- parserOptions: {
- tsconfigRootDir: __dirname,
- },
- // TODO (MR): Figure out a way to not have to ignored the next config .js
- ignorePatterns: [".eslintrc.js", "next.config.base.js"],
+ ignorePatterns: ["next.config.base.js"],
};
diff --git a/web/packages/next/README.md b/web/packages/next/README.md
index 4a4dc45badade0faa6e50d2c071b391eb08e8c9d..01abfa42c165bb96b17dbf20f53ffc1cefeb9421 100644
--- a/web/packages/next/README.md
+++ b/web/packages/next/README.md
@@ -1,9 +1,8 @@
## @/next
-A base package for our UI layer code, for sharing ode between our Next.js apps.
+A base UI layer package for sharing code between our Next.js apps.
### Packaging
This (internal) package exports a React TypeScript library. We rely on the
importing project to transpile and bundle it.
-
diff --git a/web/packages/utils/env.ts b/web/packages/next/env.ts
similarity index 100%
rename from web/packages/utils/env.ts
rename to web/packages/next/env.ts
diff --git a/web/packages/next/hello.ts b/web/packages/next/hello.ts
deleted file mode 100644
index 6e26a0ea88b4cb55fbddfd9686a251a6c518dca1..0000000000000000000000000000000000000000
--- a/web/packages/next/hello.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-/** Howdy! */
-export const sayNamaste = () => {
- console.log("Namaste, world");
-};
diff --git a/web/packages/next/i18n.ts b/web/packages/next/i18n.ts
index 9f0f2a39707e28f7a12abb868abc86b06c7ce894..3f51d5c101ce6486ce8c159f32a8319d12b9edf5 100644
--- a/web/packages/next/i18n.ts
+++ b/web/packages/next/i18n.ts
@@ -1,9 +1,4 @@
-import { isDevBuild } from "@/utils/env";
-import {
- getLSString,
- removeLSString,
- setLSString,
-} from "@/utils/local-storage";
+import { isDevBuild } from "@/next/env";
import { logError } from "@/utils/logging";
import { includes } from "@/utils/type-guards";
import { getUserLocales } from "get-user-locale";
@@ -119,8 +114,8 @@ export const setupI18n = async () => {
* If it finds a locale stored in the old format, it also updates the saved
* value and returns it in the new format.
*/
-const savedLocaleStringMigratingIfNeeded = () => {
- const ls = getLSString("locale");
+const savedLocaleStringMigratingIfNeeded = (): SupportedLocale | undefined => {
+ const ls = localStorage.getItem("locale");
// An older version of our code had stored only the language code, not the
// full locale. Migrate these to the new locale format. Luckily, all such
@@ -133,7 +128,7 @@ const savedLocaleStringMigratingIfNeeded = () => {
if (!ls) {
// Nothing found
- return ls;
+ return undefined;
}
if (includes(supportedLocales, ls)) {
@@ -150,12 +145,12 @@ const savedLocaleStringMigratingIfNeeded = () => {
// have happened, we're the only one setting it.
logError("Failed to parse locale obtained from local storage", e);
// Also remove the old key, it is not parseable by us anymore.
- removeLSString("locale");
+ localStorage.removeItem("locale");
return undefined;
}
const newValue = mapOldValue(value);
- if (newValue) setLSString("locale", newValue);
+ if (newValue) localStorage.setItem("locale", newValue);
return newValue;
};
@@ -249,6 +244,6 @@ export const getLocaleInUse = (): SupportedLocale => {
* preference that is stored in local storage.
*/
export const setLocaleInUse = async (locale: SupportedLocale) => {
- setLSString("locale", locale);
+ localStorage.setItem("locale", locale);
return i18n.changeLanguage(locale);
};
diff --git a/web/packages/shared/logging/index.ts b/web/packages/shared/logging/index.ts
index c7c793dbfebbaa5945cc7a8785a8c3f2acdfc4a2..12ff5bf6eab281e288e3f1ee2466414c9af0af47 100644
--- a/web/packages/shared/logging/index.ts
+++ b/web/packages/shared/logging/index.ts
@@ -1,4 +1,4 @@
-import { isDevBuild } from "@/utils/env";
+import { isDevBuild } from "@/next/env";
import { logError } from "@ente/shared/sentry";
import isElectron from "is-electron";
import { WorkerSafeElectronService } from "../electron/service";
diff --git a/web/packages/shared/logging/web.ts b/web/packages/shared/logging/web.ts
index 7c9e7e2ed903ce895c19b350b8a8cbbbb9685678..7b15851b861ae1eace03bc8fe258e345a2c2e7c6 100644
--- a/web/packages/shared/logging/web.ts
+++ b/web/packages/shared/logging/web.ts
@@ -1,4 +1,4 @@
-import { isDevBuild } from "@/utils/env";
+import { isDevBuild } from "@/next/env";
import { logError } from "@ente/shared/sentry";
import {
getData,
diff --git a/web/packages/utils/.eslintrc.js b/web/packages/utils/.eslintrc.js
index a73dbf5d8886e7d26a8116958a82946531c1498a..4123f0cae3a89697777aeb1cd4632d94da87b1a8 100644
--- a/web/packages/utils/.eslintrc.js
+++ b/web/packages/utils/.eslintrc.js
@@ -1,6 +1,3 @@
module.exports = {
extends: ["@/build-config/eslintrc-base"],
- parserOptions: {
- tsconfigRootDir: __dirname,
- },
};
diff --git a/web/packages/utils/local-storage.ts b/web/packages/utils/local-storage.ts
deleted file mode 100644
index 7da0b150c071d63177b361ff001222f7dd9ffe0f..0000000000000000000000000000000000000000
--- a/web/packages/utils/local-storage.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-/**
- * Keys corresponding to the items that we save in local storage.
- *
- * The type of each of the these keys is {@link LSKey}.
- *
- * Note: [Local Storage]
- *
- * Data in the local storage is persisted even after the user closes the tab (or
- * the browser itself). This is in contrast with session storage, where the data
- * is cleared when the browser tab is closed.
- *
- * The data in local storage is tied to the Document's origin (scheme + host).
- */
-export const lsKeys = ["locale"] as const;
-
-/** The type of {@link lsKeys}. */
-export type LSKey = (typeof lsKeys)[number];
-
-/**
- * Read a previously saved string from local storage
- */
-export const getLSString = (key: LSKey) => {
- const item = localStorage.getItem(key);
- if (item === null) return undefined;
- return item;
-};
-
-/**
- * Save a string in local storage
- */
-export const setLSString = (key: LSKey, value: string) => {
- localStorage.setItem(key, value);
-};
-
-/**
- * Remove an string from local storage.
- */
-export const removeLSString = (key: LSKey) => {
- localStorage.removeItem(key);
-};