chore: Move remaining auth methods to plugins (#4900)

* Move Google, Email, and Azure to plugins

* Move OIDC provider, remove old loading code

* Move AuthLogo to plugin

* AuthLogo -> PluginIcon

* Lazy load plugin settings
This commit is contained in:
Tom Moor
2023-02-19 22:52:08 -05:00
committed by GitHub
parent 667ffdeaf1
commit 21a1257d06
32 changed files with 302 additions and 314 deletions

View File

@@ -0,0 +1,45 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
className?: string;
};
function MicrosoftLogo({ size = 24, fill = "#FFF", className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.4707 4.47059H19.9999C19.9999 6.73751 20.0003 9.00442 19.9994 11.2713C17.4902 11.271 14.9804 11.2713 12.4712 11.2713C12.4703 9.00442 12.4707 6.73751 12.4707 4.47059Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.471 12.2434C14.9804 12.2426 17.4902 12.243 20 12.243V19.5294H12.4706C12.471 17.1006 12.4702 14.6718 12.471 12.2434Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 4.47059H11.5294L11.5288 11.2713H4V4.47059Z"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 12.2429C6.50974 12.2437 9.01948 12.2426 11.5292 12.2437C11.5296 14.6724 11.5292 17.1007 11.5292 19.5294H4V12.2429Z"
/>
</svg>
);
}
export default MicrosoftLogo;

View File

@@ -0,0 +1,5 @@
{
"name": "Microsoft",
"description": "Adds a Microsoft Azure authentication provider.",
"requiredEnvVars": ["AZURE_CLIENT_ID", "AZURE_CLIENT_SECRET"]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../../server/.babelrc"
}

View File

@@ -0,0 +1,139 @@
import passport from "@outlinewiki/koa-passport";
import { Strategy as AzureStrategy } from "@outlinewiki/passport-azure-ad-oauth2";
import jwt from "jsonwebtoken";
import type { Context } from "koa";
import Router from "koa-router";
import { Profile } from "passport";
import { slugifyDomain } from "@shared/utils/domains";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import { MicrosoftGraphError } from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { User } from "@server/models";
import { AuthenticationResult } from "@server/types";
import {
StateStore,
request,
getTeamFromContext,
getClientFromContext,
} from "@server/utils/passport";
const router = new Router();
const providerName = "azure";
const scopes: string[] = [];
if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
const strategy = new AzureStrategy(
{
clientID: env.AZURE_CLIENT_ID,
clientSecret: env.AZURE_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/azure.callback`,
useCommonEndpoint: true,
passReqToCallback: true,
resource: env.AZURE_RESOURCE_APP_ID,
// @ts-expect-error StateStore
store: new StateStore(),
scope: scopes,
},
async function (
ctx: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number; id_token: string },
_profile: Profile,
done: (
err: Error | null,
user: User | null,
result?: AuthenticationResult
) => void
) {
try {
// see docs for what the fields in profile represent here:
// https://docs.microsoft.com/en-us/azure/active-directory/develop/access-tokens
const profile = jwt.decode(params.id_token) as jwt.JwtPayload;
const [profileResponse, organizationResponse] = await Promise.all([
// Load the users profile from the Microsoft Graph API
// https://docs.microsoft.com/en-us/graph/api/resources/users?view=graph-rest-1.0
request(`https://graph.microsoft.com/v1.0/me`, accessToken),
// Load the organization profile from the Microsoft Graph API
// https://docs.microsoft.com/en-us/graph/api/organization-get?view=graph-rest-1.0
request(`https://graph.microsoft.com/v1.0/organization`, accessToken),
]);
if (!profileResponse) {
throw MicrosoftGraphError(
"Unable to load user profile from Microsoft Graph API"
);
}
if (!organizationResponse) {
throw MicrosoftGraphError(
"Unable to load organization info from Microsoft Graph API"
);
}
const organization = organizationResponse.value[0];
// Note: userPrincipalName is last here for backwards compatibility with
// previous versions of Outline that did not include it.
const email =
profile.email ||
profileResponse.mail ||
profileResponse.userPrincipalName;
if (!email) {
throw MicrosoftGraphError(
"'email' property is required but could not be found in user profile."
);
}
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const domain = email.split("@")[1];
const subdomain = slugifyDomain(domain);
const teamName = organization.displayName;
const result = await accountProvisioner({
ip: ctx.ip,
team: {
teamId: team?.id,
name: teamName,
domain,
subdomain,
},
user: {
name: profile.name,
email,
avatarUrl: profile.picture,
},
authenticationProvider: {
name: providerName,
providerId: profile.tid,
},
authentication: {
providerId: profile.oid,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes,
},
});
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}
}
);
passport.use(strategy);
router.get(
"azure",
passport.authenticate(providerName, { prompt: "select_account" })
);
router.get("azure.callback", passportMiddleware(providerName));
}
export default router;

View File

@@ -0,0 +1,4 @@
{
"name": "Email",
"description": "Adds an email magic link authentication provider."
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../../server/.babelrc"
}

View File

@@ -0,0 +1,221 @@
import sharedEnv from "@shared/env";
import SigninEmail from "@server/emails/templates/SigninEmail";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import env from "@server/env";
import { buildUser, buildGuestUser, buildTeam } from "@server/test/factories";
import { getTestServer } from "@server/test/support";
const server = getTestServer();
describe("email", () => {
it("should require email param", async () => {
const res = await server.post("/auth/email", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.error).toEqual("validation_error");
expect(body.ok).toEqual(false);
});
it("should respond with redirect location when user is SSO enabled", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const user = await buildUser();
const res = await server.post("/auth/email", {
body: {
email: user.email,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it("should not send email when user is on another subdomain but respond with success", async () => {
env.URL = sharedEnv.URL = "http://localoutline.com";
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
env.DEPLOYMENT = "hosted";
const user = await buildUser();
const spy = jest.spyOn(WelcomeEmail, "schedule");
await buildTeam({
subdomain: "example",
});
const res = await server.post("/auth/email", {
body: {
email: user.email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it("should respond with success and email to be sent when user is not SSO enabled", async () => {
const spy = jest.spyOn(SigninEmail, "schedule");
const team = await buildTeam({
subdomain: "example",
});
const user = await buildGuestUser({
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: {
email: user.email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
await buildTeam({
subdomain: "example",
});
const res = await server.post("/auth/email", {
body: {
email: "user@example.com",
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
describe("with multiple users matching email", () => {
it("should default to current subdomain with SSO", async () => {
const spy = jest.spyOn(SigninEmail, "schedule");
env.URL = sharedEnv.URL = "http://localoutline.com";
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
const email = "sso-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildGuestUser({
email,
});
await buildUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: {
email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it("should default to current subdomain with guest email", async () => {
const spy = jest.spyOn(SigninEmail, "schedule");
env.URL = sharedEnv.URL = "http://localoutline.com";
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
const email = "guest-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildUser({
email,
});
await buildGuestUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: {
email,
},
headers: {
host: "example.localoutline.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
it("should default to custom domain with SSO", async () => {
const spy = jest.spyOn(WelcomeEmail, "schedule");
const email = "sso-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildGuestUser({
email,
});
await buildUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: {
email,
},
headers: {
host: "docs.mycompany.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(spy).not.toHaveBeenCalled();
spy.mockRestore();
});
it("should default to custom domain with guest email", async () => {
const spy = jest.spyOn(SigninEmail, "schedule");
const email = "guest-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildUser({
email,
});
await buildGuestUser({
email,
teamId: team.id,
});
const res = await server.post("/auth/email", {
body: {
email,
},
headers: {
host: "docs.mycompany.com",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});
});

View File

@@ -0,0 +1,138 @@
import Router from "koa-router";
import { find } from "lodash";
import { Client } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import InviteAcceptedEmail from "@server/emails/templates/InviteAcceptedEmail";
import SigninEmail from "@server/emails/templates/SigninEmail";
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
import env from "@server/env";
import { AuthorizationError } from "@server/errors";
import { rateLimiter } from "@server/middlewares/rateLimiter";
import { User, Team } from "@server/models";
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
import { signIn } from "@server/utils/authentication";
import { getUserForEmailSigninToken } from "@server/utils/jwt";
import { assertEmail, assertPresent } from "@server/validation";
const router = new Router();
router.post(
"email",
rateLimiter(RateLimiterStrategy.TenPerHour),
async (ctx) => {
const { email, client } = ctx.request.body;
assertEmail(email, "email is required");
const domain = parseDomain(ctx.request.hostname);
let team: Team | null | undefined;
if (!env.isCloudHosted()) {
team = await Team.scope("withAuthenticationProviders").findOne();
} else if (domain.custom) {
team = await Team.scope("withAuthenticationProviders").findOne({
where: { domain: domain.host },
});
} else if (env.SUBDOMAINS_ENABLED && domain.teamSubdomain) {
team = await Team.scope("withAuthenticationProviders").findOne({
where: { subdomain: domain.teamSubdomain },
});
}
if (!team?.emailSigninEnabled) {
throw AuthorizationError();
}
const user = await User.scope("withAuthentications").findOne({
where: {
teamId: team.id,
email: email.toLowerCase(),
},
});
if (!user) {
ctx.body = {
success: true,
};
return;
}
// If the user matches an email address associated with an SSO
// provider then just forward them directly to that sign-in page
if (user.authentications.length) {
const authProvider = find(team.authenticationProviders, {
id: user.authentications[0].authenticationProviderId,
});
if (authProvider?.enabled) {
ctx.body = {
redirect: `${team.url}/auth/${authProvider?.name}`,
};
return;
}
}
// send email to users registered address with a short-lived token
await SigninEmail.schedule({
to: user.email,
token: user.getEmailSigninToken(),
teamUrl: team.url,
client: client === Client.Desktop ? Client.Desktop : Client.Web,
});
user.lastSigninEmailSentAt = new Date();
await user.save();
// respond with success regardless of whether an email was sent
ctx.body = {
success: true,
};
}
);
router.get("email.callback", async (ctx) => {
const { token, client } = ctx.request.query;
assertPresent(token, "token is required");
let user!: User;
try {
user = await getUserForEmailSigninToken(token as string);
} catch (err) {
ctx.redirect(`/?notice=expired-token`);
return;
}
if (!user.team.emailSigninEnabled) {
return ctx.redirect("/?notice=auth-error");
}
if (user.isSuspended) {
return ctx.redirect("/?notice=suspended");
}
if (user.isInvited) {
await WelcomeEmail.schedule({
to: user.email,
teamUrl: user.team.url,
});
const inviter = await user.$get("invitedBy");
if (inviter) {
await InviteAcceptedEmail.schedule({
to: inviter.email,
inviterId: inviter.id,
invitedName: user.name,
teamUrl: user.team.url,
});
}
}
// set cookies on response and redirect to team subdomain
await signIn(ctx, "email", {
user,
team: user.team,
isNewTeam: false,
isNewUser: false,
client: client === Client.Desktop ? Client.Desktop : Client.Web,
});
});
export default router;

View File

@@ -0,0 +1,26 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
className?: string;
};
function GoogleLogo({ size = 24, fill = "currentColor", className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.2312 10.5455H11.8276V13.6364H16.0892C15.6919 15.6 14.0306 16.7273 11.8276 16.7273C9.22733 16.7273 7.13267 14.6182 7.13267 12C7.13267 9.38182 9.22733 7.27273 11.8276 7.27273C12.9472 7.27273 13.9584 7.67273 14.7529 8.32727L17.0643 6C15.6558 4.76364 13.85 4 11.8276 4C7.42159 4 3.88232 7.56364 3.88232 12C3.88232 16.4364 7.42159 20 11.8276 20C15.8002 20 19.4117 17.0909 19.4117 12C19.4117 11.5273 19.3395 11.0182 19.2312 10.5455Z" />
</svg>
);
}
export default GoogleLogo;

View File

@@ -0,0 +1,5 @@
{
"name": "Google",
"description": "Adds a Google authentication provider.",
"requiredEnvVars": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../../server/.babelrc"
}

View File

@@ -0,0 +1,144 @@
import passport from "@outlinewiki/koa-passport";
import type { Context } from "koa";
import Router from "koa-router";
import { capitalize } from "lodash";
import { Profile } from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
import { slugifyDomain } from "@shared/utils/domains";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import {
GmailAccountCreationError,
TeamDomainRequiredError,
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { User } from "@server/models";
import { AuthenticationResult } from "@server/types";
import {
StateStore,
getTeamFromContext,
getClientFromContext,
} from "@server/utils/passport";
const router = new Router();
const providerName = "google";
const scopes = [
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/userinfo.email",
];
type GoogleProfile = Profile & {
email: string;
picture: string;
_json: {
hd?: string;
};
};
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
passport.use(
new GoogleStrategy(
{
clientID: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/google.callback`,
passReqToCallback: true,
// @ts-expect-error StateStore
store: new StateStore(),
scope: scopes,
},
async function (
ctx: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number },
profile: GoogleProfile,
done: (
err: Error | null,
user: User | null,
result?: AuthenticationResult
) => void
) {
try {
// "domain" is the Google Workspaces domain
const domain = profile._json.hd;
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
// No profile domain means personal gmail account
// No team implies the request came from the apex domain
// This combination is always an error
if (!domain && !team) {
const userExists = await User.count({
where: { email: profile.email.toLowerCase() },
});
// Users cannot create a team with personal gmail accounts
if (!userExists) {
throw GmailAccountCreationError();
}
// To log-in with a personal account, users must specify a team subdomain
throw TeamDomainRequiredError();
}
// remove the TLD and form a subdomain from the remaining
// subdomains of the form "foo.bar.com" are allowed as primary Google Workspaces domains
// see https://support.google.com/nonprofits/thread/19685140/using-a-subdomain-as-a-primary-domain
const subdomain = domain ? slugifyDomain(domain) : "";
const teamName = capitalize(subdomain);
// Request a larger size profile picture than the default by tweaking
// the query parameter.
const avatarUrl = profile.picture.replace("=s96-c", "=s128-c");
// if a team can be inferred, we assume the user is only interested in signing into
// that team in particular; otherwise, we will do a best effort at finding their account
// or provisioning a new one (within AccountProvisioner)
const result = await accountProvisioner({
ip: ctx.ip,
team: {
teamId: team?.id,
name: teamName,
domain,
subdomain,
},
user: {
email: profile.email,
name: profile.displayName,
avatarUrl,
},
authenticationProvider: {
name: providerName,
providerId: domain ?? "",
},
authentication: {
providerId: profile.id,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes,
},
});
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}
}
)
);
router.get(
"google",
passport.authenticate(providerName, {
accessType: "offline",
prompt: "select_account consent",
})
);
router.get("google.callback", passportMiddleware(providerName));
}
export default router;

12
plugins/oidc/plugin.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "OIDC",
"description": "Adds an OpenID compatible authentication provider.",
"requiredEnvVars": [
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_AUTH_URI",
"OIDC_TOKEN_URI",
"OIDC_USERINFO_URI",
"OIDC_DISPLAY_NAME"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../../server/.babelrc"
}

View File

@@ -0,0 +1,147 @@
import passport from "@outlinewiki/koa-passport";
import type { Context } from "koa";
import Router from "koa-router";
import { get } from "lodash";
import { Strategy } from "passport-oauth2";
import { slugifyDomain } from "@shared/utils/domains";
import accountProvisioner from "@server/commands/accountProvisioner";
import env from "@server/env";
import {
OIDCMalformedUserInfoError,
AuthenticationError,
} from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { User } from "@server/models";
import { AuthenticationResult } from "@server/types";
import {
StateStore,
request,
getTeamFromContext,
getClientFromContext,
} from "@server/utils/passport";
const router = new Router();
const providerName = "oidc";
const scopes = env.OIDC_SCOPES.split(" ");
Strategy.prototype.userProfile = async function (accessToken, done) {
try {
const response = await request(env.OIDC_USERINFO_URI ?? "", accessToken);
return done(null, response);
} catch (err) {
return done(err);
}
};
if (
env.OIDC_CLIENT_ID &&
env.OIDC_CLIENT_SECRET &&
env.OIDC_AUTH_URI &&
env.OIDC_TOKEN_URI &&
env.OIDC_USERINFO_URI
) {
passport.use(
providerName,
new Strategy(
{
authorizationURL: env.OIDC_AUTH_URI,
tokenURL: env.OIDC_TOKEN_URI,
clientID: env.OIDC_CLIENT_ID,
clientSecret: env.OIDC_CLIENT_SECRET,
callbackURL: `${env.URL}/auth/${providerName}.callback`,
passReqToCallback: true,
scope: env.OIDC_SCOPES,
// @ts-expect-error custom state store
store: new StateStore(),
state: true,
pkce: false,
},
// OpenID Connect standard profile claims can be found in the official
// specification.
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
// Non-standard claims may be configured by individual identity providers.
// Any claim supplied in response to the userinfo request will be
// available on the `profile` parameter
async function (
ctx: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number },
profile: Record<string, string>,
done: (
err: Error | null,
user: User | null,
result?: AuthenticationResult
) => void
) {
try {
if (!profile.email) {
throw AuthenticationError(
`An email field was not returned in the profile parameter, but is required.`
);
}
if (!profile.name) {
throw AuthenticationError(
`A name field was not returned in the profile parameter, but is required.`
);
}
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
const parts = profile.email.toLowerCase().split("@");
const domain = parts.length && parts[1];
if (!domain) {
throw OIDCMalformedUserInfoError();
}
// remove the TLD and form a subdomain from the remaining
const subdomain = slugifyDomain(domain);
// Claim name can be overriden using an env variable.
// Default is 'preferred_username' as per OIDC spec.
const username = get(profile, env.OIDC_USERNAME_CLAIM);
const result = await accountProvisioner({
ip: ctx.ip,
team: {
teamId: team?.id,
// https://github.com/outline/outline/pull/2388#discussion_r681120223
name: "Wiki",
domain,
subdomain,
},
user: {
name: profile.name || username || profile.username,
email: profile.email,
avatarUrl: profile.picture,
username,
},
authenticationProvider: {
name: providerName,
providerId: domain,
},
authentication: {
providerId: profile.sub,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes,
},
});
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}
}
)
);
router.get(providerName, passport.authenticate(providerName));
router.get(`${providerName}.callback`, passportMiddleware(providerName));
}
export const name = env.OIDC_DISPLAY_NAME;
export default router;

View File

@@ -4,19 +4,19 @@ type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
fill?: string;
};
export default function Icon({ size = 24, color = "currentColor" }: Props) {
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={color}
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path d="M7.36156352,14.1107492 C7.36156352,15.0358306 6.60586319,15.7915309 5.68078176,15.7915309 C4.75570033,15.7915309 4,15.0358306 4,14.1107492 C4,13.1856678 4.75570033,12.4299674 5.68078176,12.4299674 L7.36156352,12.4299674 L7.36156352,14.1107492 Z M8.20846906,14.1107492 C8.20846906,13.1856678 8.96416938,12.4299674 9.88925081,12.4299674 C10.8143322,12.4299674 11.5700326,13.1856678 11.5700326,14.1107492 L11.5700326,18.3192182 C11.5700326,19.2442997 10.8143322,20 9.88925081,20 C8.96416938,20 8.20846906,19.2442997 8.20846906,18.3192182 C8.20846906,18.3192182 8.20846906,14.1107492 8.20846906,14.1107492 Z M9.88925081,7.36156352 C8.96416938,7.36156352 8.20846906,6.60586319 8.20846906,5.68078176 C8.20846906,4.75570033 8.96416938,4 9.88925081,4 C10.8143322,4 11.5700326,4.75570033 11.5700326,5.68078176 L11.5700326,7.36156352 L9.88925081,7.36156352 Z M9.88925081,8.20846906 C10.8143322,8.20846906 11.5700326,8.96416938 11.5700326,9.88925081 C11.5700326,10.8143322 10.8143322,11.5700326 9.88925081,11.5700326 L5.68078176,11.5700326 C4.75570033,11.5700326 4,10.8143322 4,9.88925081 C4,8.96416938 4.75570033,8.20846906 5.68078176,8.20846906 C5.68078176,8.20846906 9.88925081,8.20846906 9.88925081,8.20846906 Z M16.6384365,9.88925081 C16.6384365,8.96416938 17.3941368,8.20846906 18.3192182,8.20846906 C19.2442997,8.20846906 20,8.96416938 20,9.88925081 C20,10.8143322 19.2442997,11.5700326 18.3192182,11.5700326 L16.6384365,11.5700326 L16.6384365,9.88925081 Z M15.7915309,9.88925081 C15.7915309,10.8143322 15.0358306,11.5700326 14.1107492,11.5700326 C13.1856678,11.5700326 12.4299674,10.8143322 12.4299674,9.88925081 L12.4299674,5.68078176 C12.4299674,4.75570033 13.1856678,4 14.1107492,4 C15.0358306,4 15.7915309,4.75570033 15.7915309,5.68078176 L15.7915309,9.88925081 Z M14.1107492,16.6384365 C15.0358306,16.6384365 15.7915309,17.3941368 15.7915309,18.3192182 C15.7915309,19.2442997 15.0358306,20 14.1107492,20 C13.1856678,20 12.4299674,19.2442997 12.4299674,18.3192182 L12.4299674,16.6384365 L14.1107492,16.6384365 Z M14.1107492,15.7915309 C13.1856678,15.7915309 12.4299674,15.0358306 12.4299674,14.1107492 C12.4299674,13.1856678 13.1856678,12.4299674 14.1107492,12.4299674 L18.3192182,12.4299674 C19.2442997,12.4299674 20,13.1856678 20,14.1107492 C20,15.0358306 19.2442997,15.7915309 18.3192182,15.7915309 L14.1107492,15.7915309 Z" />
<path d="M7.36156 14.1107C7.36156 15.0358 6.60586 15.7915 5.68078 15.7915C4.7557 15.7915 4 15.0358 4 14.1107C4 13.1857 4.7557 12.43 5.68078 12.43H7.36156V14.1107ZM8.20847 14.1107C8.20847 13.1857 8.96417 12.43 9.88925 12.43C10.8143 12.43 11.57 13.1857 11.57 14.1107V18.3192C11.57 19.2443 10.8143 20 9.88925 20C8.96417 20 8.20847 19.2443 8.20847 18.3192V14.1107ZM9.88925 7.36156C8.96417 7.36156 8.20847 6.60586 8.20847 5.68078C8.20847 4.7557 8.96417 4 9.88925 4C10.8143 4 11.57 4.7557 11.57 5.68078V7.36156H9.88925ZM9.88925 8.20847C10.8143 8.20847 11.57 8.96417 11.57 9.88925C11.57 10.8143 10.8143 11.57 9.88925 11.57H5.68078C4.7557 11.57 4 10.8143 4 9.88925C4 8.96417 4.7557 8.20847 5.68078 8.20847H9.88925ZM16.6384 9.88925C16.6384 8.96417 17.3941 8.20847 18.3192 8.20847C19.2443 8.20847 20 8.96417 20 9.88925C20 10.8143 19.2443 11.57 18.3192 11.57H16.6384V9.88925ZM15.7915 9.88925C15.7915 10.8143 15.0358 11.57 14.1107 11.57C13.1857 11.57 12.43 10.8143 12.43 9.88925V5.68078C12.43 4.7557 13.1857 4 14.1107 4C15.0358 4 15.7915 4.7557 15.7915 5.68078V9.88925ZM14.1107 16.6384C15.0358 16.6384 15.7915 17.3941 15.7915 18.3192C15.7915 19.2443 15.0358 20 14.1107 20C13.1857 20 12.43 19.2443 12.43 18.3192V16.6384H14.1107ZM14.1107 15.7915C13.1857 15.7915 12.43 15.0358 12.43 14.1107C12.43 13.1857 13.1857 12.43 14.1107 12.43H18.3192C19.2443 12.43 20 13.1857 20 14.1107C20 15.0358 19.2443 15.7915 18.3192 15.7915H14.1107Z" />
</svg>
);
}

View File

@@ -56,7 +56,7 @@ function Slack() {
const appName = env.APP_NAME;
return (
<Scene title="Slack" icon={<SlackIcon color="currentColor" />}>
<Scene title="Slack" icon={<SlackIcon />}>
<Heading>Slack</Heading>
{error === "access_denied" && (
@@ -106,7 +106,7 @@ function Slack() {
]}
redirectUri={`${env.URL}/auth/slack.commands`}
state={team.id}
icon={<SlackIcon color="currentColor" />}
icon={<SlackIcon />}
/>
)}
</p>