feat: allowed toggling login options as admin

This commit is contained in:
Sheen Capadngan
2024-06-26 22:45:14 +08:00
parent 2e3aedc62b
commit 98a15a901e
8 changed files with 488 additions and 146 deletions
@@ -0,0 +1,19 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.SuperAdmin, "enabledLoginMethods"))) {
await knex.schema.alterTable(TableName.SuperAdmin, (tb) => {
tb.specificType("enabledLoginMethods", "text[]");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.SuperAdmin, "enabledLoginMethods")) {
await knex.schema.alterTable(TableName.SuperAdmin, (t) => {
t.dropColumn("enabledLoginMethods");
});
}
}
+2 -1
View File
@@ -18,7 +18,8 @@ export const SuperAdminSchema = z.object({
trustSamlEmails: z.boolean().default(false).nullable().optional(), trustSamlEmails: z.boolean().default(false).nullable().optional(),
trustLdapEmails: z.boolean().default(false).nullable().optional(), trustLdapEmails: z.boolean().default(false).nullable().optional(),
trustOidcEmails: z.boolean().default(false).nullable().optional(), trustOidcEmails: z.boolean().default(false).nullable().optional(),
defaultAuthOrgId: z.string().uuid().nullable().optional() defaultAuthOrgId: z.string().uuid().nullable().optional(),
enabledLoginMethods: z.string().array().nullable().optional()
}); });
export type TSuperAdmin = z.infer<typeof SuperAdminSchema>; export type TSuperAdmin = z.infer<typeof SuperAdminSchema>;
+9 -1
View File
@@ -8,6 +8,7 @@ import { verifySuperAdmin } from "@app/server/plugins/auth/superAdmin";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { getServerCfg } from "@app/services/super-admin/super-admin-service"; import { getServerCfg } from "@app/services/super-admin/super-admin-service";
import { LoginMethod } from "@app/services/super-admin/super-admin-types";
import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types"; import { PostHogEventTypes } from "@app/services/telemetry/telemetry-types";
export const registerAdminRouter = async (server: FastifyZodProvider) => { export const registerAdminRouter = async (server: FastifyZodProvider) => {
@@ -54,7 +55,14 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
trustSamlEmails: z.boolean().optional(), trustSamlEmails: z.boolean().optional(),
trustLdapEmails: z.boolean().optional(), trustLdapEmails: z.boolean().optional(),
trustOidcEmails: z.boolean().optional(), trustOidcEmails: z.boolean().optional(),
defaultAuthOrgId: z.string().optional().nullable() defaultAuthOrgId: z.string().optional().nullable(),
enabledLoginMethods: z
.nativeEnum(LoginMethod)
.array()
.optional()
.refine((methods) => !methods || methods.length > 0, {
message: "At least one login method should be enabled."
})
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -15,3 +15,13 @@ export type TAdminSignUpDTO = {
ip: string; ip: string;
userAgent: string; userAgent: string;
}; };
export enum LoginMethod {
EMAIL = "email",
GOOGLE = "google",
GITHUB = "github",
GITLAB = "gitlab",
SAML = "saml",
LDAP = "ldap",
OIDC = "oidc"
}
+11
View File
@@ -1,3 +1,13 @@
export enum LoginMethod {
EMAIL = "email",
GOOGLE = "google",
GITHUB = "github",
GITLAB = "gitlab",
SAML = "saml",
LDAP = "ldap",
OIDC = "oidc"
}
export type TServerConfig = { export type TServerConfig = {
initialized: boolean; initialized: boolean;
allowSignUp: boolean; allowSignUp: boolean;
@@ -9,6 +19,7 @@ export type TServerConfig = {
isSecretScanningDisabled: boolean; isSecretScanningDisabled: boolean;
defaultAuthOrgSlug: string | null; defaultAuthOrgSlug: string | null;
defaultAuthOrgId: string | null; defaultAuthOrgId: string | null;
enabledLoginMethods: LoginMethod[];
}; };
export type TCreateAdminUserDTO = { export type TCreateAdminUserDTO = {
@@ -15,6 +15,7 @@ import { CAPTCHA_SITE_KEY } from "@app/components/utilities/config";
import { Button, Input } from "@app/components/v2"; import { Button, Input } from "@app/components/v2";
import { useServerConfig } from "@app/context"; import { useServerConfig } from "@app/context";
import { useFetchServerStatus } from "@app/hooks/api"; import { useFetchServerStatus } from "@app/hooks/api";
import { LoginMethod } from "@app/hooks/api/admin/types";
import { useNavigateToSelectOrganization } from "../../Login.utils"; import { useNavigateToSelectOrganization } from "../../Login.utils";
@@ -162,154 +163,188 @@ export const InitialStep = ({ setStep, email, setEmail, password, setPassword }:
<h1 className="mb-8 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent"> <h1 className="mb-8 bg-gradient-to-b from-white to-bunker-200 bg-clip-text text-center text-xl font-medium text-transparent">
Login to Infisical Login to Infisical
</h1> </h1>
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6"> {!config.enabledLoginMethods ||
<Button (config.enabledLoginMethods.includes(LoginMethod.GOOGLE) && (
colorSchema="primary" <div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
variant="outline_bg" <Button
onClick={() => { colorSchema="primary"
const callbackPort = queryParams.get("callback_port"); variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
window.open( window.open(
`/api/v1/sso/redirect/google${callbackPort ? `?callback_port=${callbackPort}` : ""}` `/api/v1/sso/redirect/google${
); callbackPort ? `?callback_port=${callbackPort}` : ""
window.close(); }`
}} );
leftIcon={<FontAwesomeIcon icon={faGoogle} className="mr-2" />} window.close();
className="mx-0 h-10 w-full" }}
> leftIcon={<FontAwesomeIcon icon={faGoogle} className="mr-2" />}
{t("login.continue-with-google")} className="mx-0 h-10 w-full"
</Button> >
</div> {t("login.continue-with-google")}
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6"> </Button>
<Button </div>
colorSchema="primary" ))}
variant="outline_bg" {!config.enabledLoginMethods ||
onClick={() => { (config.enabledLoginMethods.includes(LoginMethod.GITHUB) && (
const callbackPort = queryParams.get("callback_port"); <div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
colorSchema="primary"
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
window.open( window.open(
`/api/v1/sso/redirect/github${callbackPort ? `?callback_port=${callbackPort}` : ""}` `/api/v1/sso/redirect/github${
); callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
window.close(); window.close();
}} }}
leftIcon={<FontAwesomeIcon icon={faGithub} className="mr-2" />} leftIcon={<FontAwesomeIcon icon={faGithub} className="mr-2" />}
className="mx-0 h-10 w-full" className="mx-0 h-10 w-full"
> >
Continue with GitHub Continue with GitHub
</Button> </Button>
</div> </div>
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6"> ))}
<Button {!config.enabledLoginMethods ||
colorSchema="primary" (config.enabledLoginMethods.includes(LoginMethod.GITLAB) && (
variant="outline_bg" <div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
onClick={() => { <Button
const callbackPort = queryParams.get("callback_port"); colorSchema="primary"
variant="outline_bg"
onClick={() => {
const callbackPort = queryParams.get("callback_port");
window.open( window.open(
`/api/v1/sso/redirect/gitlab${callbackPort ? `?callback_port=${callbackPort}` : ""}` `/api/v1/sso/redirect/gitlab${
); callbackPort ? `?callback_port=${callbackPort}` : ""
}`
);
window.close(); window.close();
}} }}
leftIcon={<FontAwesomeIcon icon={faGitlab} className="mr-2" />} leftIcon={<FontAwesomeIcon icon={faGitlab} className="mr-2" />}
className="mx-0 h-10 w-full" className="mx-0 h-10 w-full"
> >
Continue with GitLab Continue with GitLab
</Button> </Button>
</div> </div>
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6"> ))}
<Button {!config.enabledLoginMethods ||
colorSchema="primary" (config.enabledLoginMethods.includes(LoginMethod.SAML) && (
variant="outline_bg" <div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
onClick={() => { <Button
handleSaml(2); colorSchema="primary"
}} variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />} onClick={() => {
className="mx-0 h-10 w-full" handleSaml(2);
> }}
Continue with SAML leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
</Button> className="mx-0 h-10 w-full"
</div> >
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6"> Continue with SAML
<Button </Button>
colorSchema="primary" </div>
variant="outline_bg" ))}
onClick={() => { {!config.enabledLoginMethods ||
setStep(3); (config.enabledLoginMethods.includes(LoginMethod.OIDC) && (
}} <div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />} <Button
className="mx-0 h-10 w-full" colorSchema="primary"
> variant="outline_bg"
Continue with OIDC onClick={() => {
</Button> setStep(3);
</div> }}
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6"> leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
<Button className="mx-0 h-10 w-full"
colorSchema="primary" >
variant="outline_bg" Continue with OIDC
onClick={() => { </Button>
router.push("/login/ldap"); </div>
}} ))}
leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />} {!config.enabledLoginMethods ||
className="mx-0 h-10 w-full" (config.enabledLoginMethods.includes(LoginMethod.LDAP) && (
> <div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
Continue with LDAP <Button
</Button> colorSchema="primary"
</div> variant="outline_bg"
<div className="my-4 flex w-1/4 min-w-[20rem] flex-row items-center py-2 lg:w-1/6"> onClick={() => {
<div className="w-full border-t border-mineshaft-400/60" /> router.push("/login/ldap");
<span className="mx-2 text-xs text-mineshaft-200">or</span> }}
<div className="w-full border-t border-mineshaft-400/60" /> leftIcon={<FontAwesomeIcon icon={faLock} className="mr-2" />}
</div> className="mx-0 h-10 w-full"
<div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6"> >
<Input Continue with LDAP
value={email} </Button>
onChange={(e) => setEmail(e.target.value)} </div>
type="email" ))}
placeholder="Enter your email..."
isRequired {!config.enabledLoginMethods ||
autoComplete="username" (config.enabledLoginMethods.length > 1 &&
className="h-10" config.enabledLoginMethods.includes(LoginMethod.EMAIL) && (
/> <div className="my-4 flex w-1/4 min-w-[20rem] flex-row items-center py-2 lg:w-1/6">
</div> <div className="w-full border-t border-mineshaft-400/60" />
<div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6"> <span className="mx-2 text-xs text-mineshaft-200">or</span>
<Input <div className="w-full border-t border-mineshaft-400/60" />
value={password} </div>
onChange={(e) => setPassword(e.target.value)} ))}
type="password" {!config.enabledLoginMethods ||
placeholder="Enter your password..." (config.enabledLoginMethods.includes(LoginMethod.EMAIL) && (
isRequired <>
autoComplete="current-password" <div className="w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
id="current-password" <Input
className="select:-webkit-autofill:focus h-10" value={email}
/> onChange={(e) => setEmail(e.target.value)}
</div> type="email"
{shouldShowCaptcha && ( placeholder="Enter your email..."
<div className="mt-4"> isRequired
<HCaptcha autoComplete="username"
theme="dark" className="h-10"
sitekey={CAPTCHA_SITE_KEY} />
onVerify={(token) => setCaptchaToken(token)} </div>
ref={captchaRef} <div className="mt-2 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
/> <Input
</div> value={password}
)} onChange={(e) => setPassword(e.target.value)}
<div className="mt-3 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6"> type="password"
<Button placeholder="Enter your password..."
disabled={shouldShowCaptcha && captchaToken === ""} isRequired
type="submit" autoComplete="current-password"
size="sm" id="current-password"
isFullWidth className="select:-webkit-autofill:focus h-10"
className="h-10" />
colorSchema="primary" </div>
variant="solid" {shouldShowCaptcha && (
isLoading={isLoading} <div className="mt-4">
> <HCaptcha
{" "} theme="dark"
Continue with Email{" "} sitekey={CAPTCHA_SITE_KEY}
</Button> onVerify={(token) => setCaptchaToken(token)}
</div> ref={captchaRef}
/>
</div>
)}
<div className="mt-3 w-1/4 min-w-[21.2rem] rounded-md text-center md:min-w-[20.1rem] lg:w-1/6">
<Button
disabled={shouldShowCaptcha && captchaToken === ""}
type="submit"
size="sm"
isFullWidth
className="h-10"
colorSchema="primary"
variant="solid"
isLoading={isLoading}
>
{" "}
Continue with Email{" "}
</Button>
</div>
</>
))}
{!isLoading && loginError && <Error text={t("login.error-login") ?? ""} />} {!isLoading && loginError && <Error text={t("login.error-login") ?? ""} />}
{config.allowSignUp ? ( {config.allowSignUp ? (
<div className="mt-6 flex flex-row text-sm text-bunker-400"> <div className="mt-6 flex flex-row text-sm text-bunker-400">
@@ -0,0 +1,251 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Switch } from "@app/components/v2";
import { useServerConfig } from "@app/context";
import { useUpdateServerConfig } from "@app/hooks/api";
import { LoginMethod } from "@app/hooks/api/admin/types";
const formSchema = z.object({
isEmailEnabled: z.boolean(),
isGoogleEnabled: z.boolean(),
isGithubEnabled: z.boolean(),
isGitlabEnabled: z.boolean(),
isSamlEnabled: z.boolean(),
isLdapEnabled: z.boolean(),
isOidcEnabled: z.boolean()
});
type TAuthForm = z.infer<typeof formSchema>;
export const AuthPanel = () => {
const { config } = useServerConfig();
const { enabledLoginMethods } = config;
const { mutateAsync: updateServerConfig } = useUpdateServerConfig();
const {
control,
handleSubmit,
formState: { isSubmitting, isDirty }
} = useForm<TAuthForm>({
resolver: zodResolver(formSchema),
values: enabledLoginMethods
? {
isEmailEnabled: enabledLoginMethods.includes(LoginMethod.EMAIL),
isGoogleEnabled: enabledLoginMethods.includes(LoginMethod.GOOGLE),
isGithubEnabled: enabledLoginMethods.includes(LoginMethod.GITHUB),
isGitlabEnabled: enabledLoginMethods.includes(LoginMethod.GITLAB),
isSamlEnabled: enabledLoginMethods.includes(LoginMethod.SAML),
isLdapEnabled: enabledLoginMethods.includes(LoginMethod.LDAP),
isOidcEnabled: enabledLoginMethods.includes(LoginMethod.OIDC)
}
: {
isEmailEnabled: true,
isGoogleEnabled: true,
isGithubEnabled: true,
isGitlabEnabled: true,
isSamlEnabled: true,
isLdapEnabled: true,
isOidcEnabled: true
}
});
const onAuthFormSubmit = async (formData: TAuthForm) => {
try {
const enabledMethods: LoginMethod[] = [];
if (formData.isEmailEnabled) {
enabledMethods.push(LoginMethod.EMAIL);
}
if (formData.isGoogleEnabled) {
enabledMethods.push(LoginMethod.GOOGLE);
}
if (formData.isGithubEnabled) {
enabledMethods.push(LoginMethod.GITHUB);
}
if (formData.isGitlabEnabled) {
enabledMethods.push(LoginMethod.GITLAB);
}
if (formData.isSamlEnabled) {
enabledMethods.push(LoginMethod.SAML);
}
if (formData.isLdapEnabled) {
enabledMethods.push(LoginMethod.LDAP);
}
if (formData.isOidcEnabled) {
enabledMethods.push(LoginMethod.OIDC);
}
if (!enabledMethods.length) {
createNotification({
type: "error",
text: "At least one login method should be enabled."
});
return;
}
await updateServerConfig({
enabledLoginMethods: enabledMethods
});
createNotification({
text: "Login methods have been successfully updated.",
type: "success"
});
} catch (e) {
console.error(e);
createNotification({
type: "error",
text: "Failed to update login methods."
});
}
};
return (
<form
className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4"
onSubmit={handleSubmit(onAuthFormSubmit)}
>
<div className="flex flex-col justify-start">
<div className="mb-2 text-xl font-semibold text-mineshaft-100">Login Methods</div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select the login methods available to users
</div>
<Controller
control={control}
name="isEmailEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="email-enabled"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">Email</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isGoogleEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="google-enabled"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">Google SSO</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isGithubEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-github"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">Github SSO</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isGitlabEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-gitlab"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">Gitlab SSO</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isSamlEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-saml"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">SAML SSO</p>
</Switch>
</FormControl>
);
}}
/>
<Controller
control={control}
name="isOidcEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-oidc"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">OIDC SSO</p>
</Switch>
</FormControl>
);
}}
/>
</div>
<Controller
control={control}
name="isLdapEnabled"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
id="enable-ldap"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-24">LDAP</p>
</Switch>
</FormControl>
);
}}
/>
<Button
className="mt-2"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
Save
</Button>
</form>
);
};
@@ -24,10 +24,12 @@ import {
import { useOrganization, useServerConfig, useUser } from "@app/context"; import { useOrganization, useServerConfig, useUser } from "@app/context";
import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api"; import { useGetOrganizations, useUpdateServerConfig } from "@app/hooks/api";
import { AuthPanel } from "./AuthPanel";
import { RateLimitPanel } from "./RateLimitPanel"; import { RateLimitPanel } from "./RateLimitPanel";
enum TabSections { enum TabSections {
Settings = "settings", Settings = "settings",
Auth = "auth",
RateLimit = "rate-limit" RateLimit = "rate-limit"
} }
@@ -131,6 +133,7 @@ export const AdminDashboardPage = () => {
<TabList> <TabList>
<div className="flex w-full flex-row border-b border-mineshaft-600"> <div className="flex w-full flex-row border-b border-mineshaft-600">
<Tab value={TabSections.Settings}>General</Tab> <Tab value={TabSections.Settings}>General</Tab>
<Tab value={TabSections.Auth}>Auth</Tab>
<Tab value={TabSections.RateLimit}>Rate Limit</Tab> <Tab value={TabSections.RateLimit}>Rate Limit</Tab>
</div> </div>
</TabList> </TabList>
@@ -203,7 +206,8 @@ export const AdminDashboardPage = () => {
Default organization Default organization
</div> </div>
<div className="mb-4 max-w-sm text-sm text-mineshaft-400"> <div className="mb-4 max-w-sm text-sm text-mineshaft-400">
Select the default organization you want to set for SAML/LDAP based logins. When selected, user logins will be automatically scoped to the selected organization. Select the default organization you want to set for SAML/LDAP based logins. When
selected, user logins will be automatically scoped to the selected organization.
</div> </div>
<Controller <Controller
control={control} control={control}
@@ -310,6 +314,9 @@ export const AdminDashboardPage = () => {
</Button> </Button>
</form> </form>
</TabPanel> </TabPanel>
<TabPanel value={TabSections.Auth}>
<AuthPanel />
</TabPanel>
<TabPanel value={TabSections.RateLimit}> <TabPanel value={TabSections.RateLimit}>
<RateLimitPanel /> <RateLimitPanel />
</TabPanel> </TabPanel>