initial commit
This commit is contained in:
		
						commit
						4a80c8552b
					
				
					 46 changed files with 8309 additions and 0 deletions
				
			
		
							
								
								
									
										124
									
								
								src/accounts/MastodonOAuth2Callback.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								src/accounts/MastodonOAuth2Callback.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,124 @@
 | 
			
		|||
import { useNavigate, useSearchParams } from "@solidjs/router";
 | 
			
		||||
import {
 | 
			
		||||
  Component,
 | 
			
		||||
  Show,
 | 
			
		||||
  createSignal,
 | 
			
		||||
  createUniqueId,
 | 
			
		||||
  onMount,
 | 
			
		||||
} from "solid-js";
 | 
			
		||||
import { acceptAccountViaAuthCode } from "./stores";
 | 
			
		||||
import { $settings } from "../settings/stores";
 | 
			
		||||
import { useDocumentTitle } from "../utils";
 | 
			
		||||
import cards from "../material/cards.module.css";
 | 
			
		||||
import { LinearProgress } from "@suid/material";
 | 
			
		||||
import Img from "../material/Img";
 | 
			
		||||
import { createRestAPIClient } from "masto";
 | 
			
		||||
import { Title } from "../material/typography";
 | 
			
		||||
 | 
			
		||||
type OAuth2CallbackParams = {
 | 
			
		||||
  code?: string;
 | 
			
		||||
  error?: string;
 | 
			
		||||
  error_description?: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const MastodonOAuth2Callback: Component = () => {
 | 
			
		||||
  const progressId = createUniqueId();
 | 
			
		||||
  const titleId = createUniqueId();
 | 
			
		||||
  const [params] = useSearchParams<OAuth2CallbackParams>();
 | 
			
		||||
  const navigate = useNavigate();
 | 
			
		||||
  const setDocumentTitle = useDocumentTitle("Back from Mastodon...");
 | 
			
		||||
  const [siteImg, setSiteImg] = createSignal<{
 | 
			
		||||
    src: string;
 | 
			
		||||
    srcset?: string;
 | 
			
		||||
    blurhash: string;
 | 
			
		||||
  }>();
 | 
			
		||||
  const [siteTitle, setSiteTitle] = createSignal("the Mastodon server");
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process;
 | 
			
		||||
    if (!onGoingOAuth2Process) return;
 | 
			
		||||
    const client = createRestAPIClient({
 | 
			
		||||
      url: onGoingOAuth2Process,
 | 
			
		||||
    });
 | 
			
		||||
    const ins = await client.v2.instance.fetch();
 | 
			
		||||
    setDocumentTitle(`Back from ${ins.title}...`);
 | 
			
		||||
    setSiteTitle(ins.title);
 | 
			
		||||
 | 
			
		||||
    const srcset = []
 | 
			
		||||
    if (ins.thumbnail.versions["@1x"]) {
 | 
			
		||||
      srcset.push(`${ins.thumbnail.versions["@1x"]} 1x`)
 | 
			
		||||
    }
 | 
			
		||||
    if (ins.thumbnail.versions["@2x"]) {
 | 
			
		||||
      srcset.push(`${ins.thumbnail.versions["@2x"]} 2x`)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setSiteImg({
 | 
			
		||||
      src: ins.thumbnail.url,
 | 
			
		||||
      blurhash: ins.thumbnail.blurhash,
 | 
			
		||||
      srcset: srcset ? srcset.join(",") : undefined,
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  onMount(async () => {
 | 
			
		||||
    const onGoingOAuth2Process = $settings.get().onGoingOAuth2Process;
 | 
			
		||||
    if (onGoingOAuth2Process && params.code) {
 | 
			
		||||
      const acct = await acceptAccountViaAuthCode(
 | 
			
		||||
        onGoingOAuth2Process,
 | 
			
		||||
        params.code,
 | 
			
		||||
      );
 | 
			
		||||
      $settings.setKey('onGoingOAuth2Process', undefined)
 | 
			
		||||
      navigate('/', {replace: true})
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const error =
 | 
			
		||||
      params.error ||
 | 
			
		||||
      (onGoingOAuth2Process ? "unknown" : "oauth2_unknown_target");
 | 
			
		||||
    const errorDescription =
 | 
			
		||||
      params.error_description ||
 | 
			
		||||
      (error === "unknown"
 | 
			
		||||
        ? "Remote server sends nothing"
 | 
			
		||||
        : error === "oauth2_unknown_target"
 | 
			
		||||
          ? "Unknown OAuth2 target. This is an internal error. Please contract the application maintainer."
 | 
			
		||||
          : undefined);
 | 
			
		||||
    const urlParams = new URLSearchParams({
 | 
			
		||||
      error,
 | 
			
		||||
    });
 | 
			
		||||
    if (errorDescription) {
 | 
			
		||||
      urlParams.set("errorDescription", errorDescription);
 | 
			
		||||
    }
 | 
			
		||||
    navigate("/accounts/sign-in?" + urlParams.toString(), {
 | 
			
		||||
      replace: true,
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
  return (
 | 
			
		||||
    <div class={cards.layoutCentered}>
 | 
			
		||||
      <div class={cards.card} aria-busy="true" aria-describedby={progressId}>
 | 
			
		||||
        <LinearProgress
 | 
			
		||||
          class={[cards.cardNoPad, cards.cardGutSkip].join(' ')}
 | 
			
		||||
          id={progressId}
 | 
			
		||||
          aria-labelledby={titleId}
 | 
			
		||||
        />
 | 
			
		||||
        <Show when={siteImg()} fallback={<i aria-busy="true" aria-label="Preparing image..." style={{"height": "235px", display: "block"}}></i>}>
 | 
			
		||||
          <Img
 | 
			
		||||
            src={siteImg()?.src}
 | 
			
		||||
            srcset={siteImg()?.srcset}
 | 
			
		||||
            blurhash={siteImg()?.blurhash}
 | 
			
		||||
            class={[cards.cardNoPad, cards.cardGutSkip].join(' ')}
 | 
			
		||||
            alt={`Banner image for ${siteTitle()}`}
 | 
			
		||||
            style={{"height": "235px", "display": "block"}}
 | 
			
		||||
          />
 | 
			
		||||
        </Show>
 | 
			
		||||
 | 
			
		||||
        <Title component="h6" id={titleId}>
 | 
			
		||||
          Contracting {siteTitle}...
 | 
			
		||||
        </Title>
 | 
			
		||||
        <p>
 | 
			
		||||
          If this page stays too long, you can close this page and sign in again.
 | 
			
		||||
        </p>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default MastodonOAuth2Callback;
 | 
			
		||||
							
								
								
									
										173
									
								
								src/accounts/SignIn.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										173
									
								
								src/accounts/SignIn.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,173 @@
 | 
			
		|||
import {
 | 
			
		||||
  Component,
 | 
			
		||||
  Show,
 | 
			
		||||
  createEffect,
 | 
			
		||||
  createSelector,
 | 
			
		||||
  createSignal,
 | 
			
		||||
  createUniqueId,
 | 
			
		||||
  onMount,
 | 
			
		||||
} from "solid-js";
 | 
			
		||||
import cards from "../material/cards.module.css";
 | 
			
		||||
import TextField from "../material/TextField.js";
 | 
			
		||||
import Button from "../material/Button.js";
 | 
			
		||||
import { useDocumentTitle } from "../utils";
 | 
			
		||||
import { Title } from "../material/typography";
 | 
			
		||||
import { css } from "solid-styled";
 | 
			
		||||
import { LinearProgress } from "@suid/material";
 | 
			
		||||
import { createRestAPIClient } from "masto";
 | 
			
		||||
import { getOrRegisterApp } from "./stores";
 | 
			
		||||
import { useSearchParams } from "@solidjs/router";
 | 
			
		||||
import { $settings } from "../settings/stores";
 | 
			
		||||
 | 
			
		||||
type ErrorParams = {
 | 
			
		||||
  error: string;
 | 
			
		||||
  errorDescription: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const SignIn: Component = () => {
 | 
			
		||||
  const progressId = createUniqueId();
 | 
			
		||||
  const [params] = useSearchParams<ErrorParams>();
 | 
			
		||||
  const [rawServerUrl, setRawServerUrl] = createSignal("");
 | 
			
		||||
  const [currentState, setCurrentState] = createSignal<
 | 
			
		||||
    "inactive" | "contracting" | "navigating"
 | 
			
		||||
  >("inactive");
 | 
			
		||||
  const [serverUrlHelperText, setServerUrlHelperText] = createSignal("");
 | 
			
		||||
  const [serverUrlError, setServerUrlError] = createSignal(false);
 | 
			
		||||
  const [targetSiteTitle, setTargetSiteTitle] = createSignal("");
 | 
			
		||||
 | 
			
		||||
  useDocumentTitle("Sign In");
 | 
			
		||||
  css`
 | 
			
		||||
    form {
 | 
			
		||||
      display: flex;
 | 
			
		||||
      flex-flow: column;
 | 
			
		||||
      gap: 16px;
 | 
			
		||||
    }
 | 
			
		||||
  `;
 | 
			
		||||
 | 
			
		||||
  const serverUrl = () => {
 | 
			
		||||
    const url = rawServerUrl();
 | 
			
		||||
    if (url.length === 0 || /^%w:/.test(url)) {
 | 
			
		||||
      return url;
 | 
			
		||||
    } else {
 | 
			
		||||
      return `https://${url}`;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  createEffect(() => {
 | 
			
		||||
    const url = serverUrl();
 | 
			
		||||
    setServerUrlError(false);
 | 
			
		||||
    if (url.length === 0) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    try {
 | 
			
		||||
      new URL(url);
 | 
			
		||||
    } catch {
 | 
			
		||||
      setServerUrlHelperText("Domain is required.");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    setServerUrlHelperText("");
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  onMount(() => {
 | 
			
		||||
    $settings.setKey('onGoingOAuth2Process', undefined)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const onStartOAuth2 = async (e: Event) => {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    setCurrentState("contracting");
 | 
			
		||||
    const url = serverUrl();
 | 
			
		||||
    try {
 | 
			
		||||
      setServerUrlError(!!serverUrlHelperText());
 | 
			
		||||
 | 
			
		||||
      const client = createRestAPIClient({
 | 
			
		||||
        url,
 | 
			
		||||
      });
 | 
			
		||||
      const ins = await client.v2.instance.fetch();
 | 
			
		||||
      setTargetSiteTitle(ins.title);
 | 
			
		||||
 | 
			
		||||
      const redirectURL = new URL(
 | 
			
		||||
        "./oauth2/mastodon",
 | 
			
		||||
        window.location.href,
 | 
			
		||||
      ).toString();
 | 
			
		||||
 | 
			
		||||
      const app = await getOrRegisterApp(url, redirectURL);
 | 
			
		||||
      if (app === null) {
 | 
			
		||||
        alert("The mastodon server could not be used with tutu.");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const authStart = new URL("./oauth/authorize", url);
 | 
			
		||||
      const searches = authStart.searchParams;
 | 
			
		||||
      const args = {
 | 
			
		||||
        response_type: "code",
 | 
			
		||||
        client_id: app.clientId,
 | 
			
		||||
        redirect_uri: redirectURL,
 | 
			
		||||
        scope: "read write push",
 | 
			
		||||
      };
 | 
			
		||||
      for (const [k, v] of Object.entries(args)) {
 | 
			
		||||
        searches.set(k, v);
 | 
			
		||||
      }
 | 
			
		||||
      $settings.setKey("onGoingOAuth2Process", url)
 | 
			
		||||
      window.location.href = authStart.toString();
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      setServerUrlHelperText(
 | 
			
		||||
        `Could not contract with the server: "${String(e)}". Please check and try again.`,
 | 
			
		||||
      );
 | 
			
		||||
      setServerUrlError(true);
 | 
			
		||||
      console.error(`Failed to contract ${url}.`, e);
 | 
			
		||||
    } finally {
 | 
			
		||||
      setCurrentState("inactive");
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div class={cards.layoutCentered}>
 | 
			
		||||
      <Show when={params.error || params.errorDescription}>
 | 
			
		||||
        <div class={cards.card} style={{ "margin-bottom": "20px" }}>
 | 
			
		||||
          <p>Authorization is failed.</p>
 | 
			
		||||
          <p>{params.errorDescription}</p>
 | 
			
		||||
          <p>
 | 
			
		||||
            Please try again later. If the problem persist, you can seek for
 | 
			
		||||
            help from the server administrator.
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </Show>
 | 
			
		||||
      <div
 | 
			
		||||
        class={/*once*/ cards.card}
 | 
			
		||||
        aria-busy={currentState() !== "inactive" ? "true" : "false"}
 | 
			
		||||
        aria-describedby={
 | 
			
		||||
          currentState() !== "inactive" ? progressId : undefined
 | 
			
		||||
        }
 | 
			
		||||
      >
 | 
			
		||||
        <LinearProgress
 | 
			
		||||
          class={[cards.cardNoPad, cards.cardGutSkip].join(" ")}
 | 
			
		||||
          id={progressId}
 | 
			
		||||
          sx={currentState() === "inactive" ? { display: "none" } : undefined}
 | 
			
		||||
        />
 | 
			
		||||
        <form onSubmit={onStartOAuth2}>
 | 
			
		||||
          <Title component="h6">Sign in with Your Mastodon Account</Title>
 | 
			
		||||
          <TextField
 | 
			
		||||
            label="Mastodon Server"
 | 
			
		||||
            name="serverUrl"
 | 
			
		||||
            onInput={setRawServerUrl}
 | 
			
		||||
            required
 | 
			
		||||
            helperText={serverUrlHelperText()}
 | 
			
		||||
            error={!!serverUrlError()}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          <div style={{ display: "flex", "justify-content": "end" }}>
 | 
			
		||||
            <Button type="submit" disabled={currentState() !== "inactive"}>
 | 
			
		||||
              {currentState() == "inactive"
 | 
			
		||||
                ? "Continue"
 | 
			
		||||
                : currentState() == "contracting"
 | 
			
		||||
                  ? `Contracting ${new URL(serverUrl()).host}...`
 | 
			
		||||
                  : `Moving to ${targetSiteTitle}`}
 | 
			
		||||
            </Button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </form>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default SignIn;
 | 
			
		||||
							
								
								
									
										174
									
								
								src/accounts/stores.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								src/accounts/stores.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,174 @@
 | 
			
		|||
import { persistentAtom } from "@nanostores/persistent";
 | 
			
		||||
import { createOAuthAPIClient, createRestAPIClient } from "masto";
 | 
			
		||||
import { action } from "nanostores";
 | 
			
		||||
 | 
			
		||||
export type Account = {
 | 
			
		||||
  site: string;
 | 
			
		||||
  accessToken: string;
 | 
			
		||||
 | 
			
		||||
  tokenType: string;
 | 
			
		||||
  scope: string;
 | 
			
		||||
  createdAt: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const $accounts = persistentAtom<Account[]>("accounts", [], {
 | 
			
		||||
  encode: JSON.stringify,
 | 
			
		||||
  decode: JSON.parse,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
interface OAuth2AccessToken {
 | 
			
		||||
  access_token: string;
 | 
			
		||||
  token_type: string;
 | 
			
		||||
  scope: string;
 | 
			
		||||
  created_at: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function oauth2TokenViaAuthCode(app: RegisteredApp, authCode: string) {
 | 
			
		||||
  const resp = await fetch(new URL("./oauth/token", app.site), {
 | 
			
		||||
    method: 'post',
 | 
			
		||||
    body: JSON.stringify({
 | 
			
		||||
      grant_type: "authorization_code",
 | 
			
		||||
      code: authCode,
 | 
			
		||||
      client_id: app.clientId,
 | 
			
		||||
      client_secret: app.clientSecret,
 | 
			
		||||
      redirect_uri: app.redirectUrl,
 | 
			
		||||
      scope: "read write push",
 | 
			
		||||
    }),
 | 
			
		||||
    headers: {
 | 
			
		||||
      "Content-Type": "application/json",
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  switch (resp.status) {
 | 
			
		||||
    case 200:
 | 
			
		||||
      return (await resp.json()) as OAuth2AccessToken;
 | 
			
		||||
    default: {
 | 
			
		||||
      const dict = await resp.json();
 | 
			
		||||
      const explain = dict.error_desciption ?? "Unknown OAuth2 Error";
 | 
			
		||||
      throw new TypeError(explain);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const acceptAccountViaAuthCode = action(
 | 
			
		||||
  $accounts,
 | 
			
		||||
  "acceptAccount",
 | 
			
		||||
  async ($store, site: string, authCode: string) => {
 | 
			
		||||
    const app = $registeredApps.get()[site];
 | 
			
		||||
    if (!app) {
 | 
			
		||||
      throw TypeError("application not found");
 | 
			
		||||
    }
 | 
			
		||||
    const token = await oauth2TokenViaAuthCode(app, authCode);
 | 
			
		||||
 | 
			
		||||
    const acct = {
 | 
			
		||||
      site: app.site,
 | 
			
		||||
      accessToken: token.access_token,
 | 
			
		||||
      tokenType: token.token_type,
 | 
			
		||||
      scope: token.scope,
 | 
			
		||||
      createdAt: token.created_at * 1000,
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const all = [...$store.get(), acct];
 | 
			
		||||
    $store.set(all);
 | 
			
		||||
 | 
			
		||||
    return acct;
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export type RegisteredApp = {
 | 
			
		||||
  site: string;
 | 
			
		||||
  clientId: string;
 | 
			
		||||
  clientSecret: string;
 | 
			
		||||
  vapidKey?: string;
 | 
			
		||||
  redirectUrl: string;
 | 
			
		||||
  scope: string;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const $registeredApps = persistentAtom<{
 | 
			
		||||
  [site: string]: RegisteredApp;
 | 
			
		||||
}>(
 | 
			
		||||
  "registeredApps",
 | 
			
		||||
  {},
 | 
			
		||||
  {
 | 
			
		||||
    encode: JSON.stringify,
 | 
			
		||||
    decode: JSON.parse,
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
async function getAppAccessToken(app: RegisteredApp) {
 | 
			
		||||
  const resp = await fetch(new URL("./oauth/token", app.site), {
 | 
			
		||||
    method: 'post',
 | 
			
		||||
    headers: {
 | 
			
		||||
      "Content-Type": "application/json",
 | 
			
		||||
    },
 | 
			
		||||
    body: JSON.stringify({
 | 
			
		||||
      client_id: app.clientId,
 | 
			
		||||
      client_secret: app.clientSecret,
 | 
			
		||||
      redirect_uri: app.redirectUrl,
 | 
			
		||||
      grant_type: "client_credentials",
 | 
			
		||||
    }),
 | 
			
		||||
  });
 | 
			
		||||
  const dict = await resp.json();
 | 
			
		||||
  return dict.access_token;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const getOrRegisterApp = action(
 | 
			
		||||
  $registeredApps,
 | 
			
		||||
  "getOrRegisterApp",
 | 
			
		||||
  async ($store, site: string, redirectUrl: string) => {
 | 
			
		||||
    const all = $store.get();
 | 
			
		||||
    const savedApp = all[site];
 | 
			
		||||
    if (savedApp && savedApp.redirectUrl === redirectUrl) {
 | 
			
		||||
      const appAccessToken = await getAppAccessToken(savedApp);
 | 
			
		||||
      if (appAccessToken) {
 | 
			
		||||
        const client = createRestAPIClient({
 | 
			
		||||
          url: site,
 | 
			
		||||
          accessToken: appAccessToken,
 | 
			
		||||
        });
 | 
			
		||||
        try {
 | 
			
		||||
          const verify = await client.v1.apps.verifyCredentials();
 | 
			
		||||
          Object.assign(savedApp, {
 | 
			
		||||
            vapidKey: verify.vapidKey,
 | 
			
		||||
          });
 | 
			
		||||
          const oauthClient = createOAuthAPIClient({
 | 
			
		||||
            url: site,
 | 
			
		||||
            accessToken: appAccessToken,
 | 
			
		||||
          });
 | 
			
		||||
          try {
 | 
			
		||||
            await oauthClient.revoke({
 | 
			
		||||
              clientId: savedApp.clientId,
 | 
			
		||||
              clientSecret: savedApp.clientSecret,
 | 
			
		||||
              token: appAccessToken,
 | 
			
		||||
            });
 | 
			
		||||
          } catch {}
 | 
			
		||||
          return savedApp;
 | 
			
		||||
        } finally {
 | 
			
		||||
          $store.set(all);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const client = createRestAPIClient({
 | 
			
		||||
      url: site,
 | 
			
		||||
    });
 | 
			
		||||
    const app = await client.v1.apps.create({
 | 
			
		||||
      clientName: "TuTu",
 | 
			
		||||
      website: "https://github.com/thislight/tutu",
 | 
			
		||||
      redirectUris: redirectUrl,
 | 
			
		||||
      scopes: "read write push",
 | 
			
		||||
    });
 | 
			
		||||
    if (!app.clientId || !app.clientSecret) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
    all[site] = {
 | 
			
		||||
      site,
 | 
			
		||||
      clientId: app.clientId,
 | 
			
		||||
      clientSecret: app.clientSecret,
 | 
			
		||||
      vapidKey: app.vapidKey ?? undefined,
 | 
			
		||||
      redirectUrl: redirectUrl,
 | 
			
		||||
      scope: "read write push",
 | 
			
		||||
    };
 | 
			
		||||
    $store.set(all);
 | 
			
		||||
    return all[site];
 | 
			
		||||
  },
 | 
			
		||||
);
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue