一键导入
add-portal-screen
Add screens or components to the portal frontend. Use when the user asks to create a new portal page, screen, tab, or UI component.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
Add screens or components to the portal frontend. Use when the user asks to create a new portal page, screen, tab, or UI component.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
Set up a fresh authgear-server development environment from scratch. Use when onboarding a new contributor, setting up a new machine, or when the user says "set up local dev from scratch" / "first-time setup". Covers the asdf + Homebrew install path on macOS; Nix users should follow CONTRIBUTING.md directly.
Audit and fix dependency vulnerabilities in Go and Node.js packages. Runs govulncheck for Go and npm audit for each package.json directory. Commits fixes directory by directory.
Update Authgear email templates using the correct source files, translation files, and commit order. Use when editing email wording, email structure, or subject lines.
Write or extend Go unit tests in this repo. Use when the user asks to add or update Go tests.
Draft or update detailed implementation plans for authgear-server specs, design changes, and docs/plans files. Use when Codex needs to turn a spec or outdated plan into a concrete implementation plan with exact files, exact methods, runtime call flow, compatibility requirements, test coverage, and atomic commit steps.
Write end-to-end (e2e) tests for authgear-server. Use when the user asks to write, add, or create e2e tests. The tests live in e2e/tests/ and are YAML-driven.
| name | add-portal-screen |
| description | Add screens or components to the portal frontend. Use when the user asks to create a new portal page, screen, tab, or UI component. |
| argument-hint | <feature name or description> |
Follow this guide when adding screens or components to the portal.
Before writing any code, read at least one existing screen from portal/src/screens/ that is similar to the feature being built. Reading the full file is preferred so you understand the complete pattern.
Good reference screens:
portal/src/screens/fraud-protection/FraudProtectionConfigurationScreen.tsxportal/src/screens/api-resources/APIResourceDetailsScreen.tsxAlso read any components under portal/src/components/ in the same feature area that exist already.
New screens go in:
portal/src/screens/<feature-name>/
<FeatureName>Screen.tsx
<FeatureName>Screen.module.css
fraud-protection, api-resources).FraudProtectionConfigurationScreen.tsx).components/.Reusable or extracted sub-components go in:
portal/src/components/<feature-name>/
<ComponentName>.tsx
<ComponentName>.module.css
components/ when it is large enough to deserve its own file.portal/src/components/common/.Every component or screen that has its own styles gets its own .module.css file. Name it identically to the .tsx file (e.g., FraudProtectionOverviewTab.module.css).
When two sibling components share many CSS classes (e.g., two card variants), they may both import from a single shared .module.css file. Name it after the dominant component (e.g., OverviewMetricCard.module.css shared by OverviewMetricCard.tsx and OverviewEnforcementCard.tsx).
A screen file always has two components:
Handles routing params, loading/error states, and form setup. Renders nothing except loading spinners and error views until data is ready. Then renders FormContainer wrapping the inner content.
const MyFeatureScreen: React.VFC = function MyFeatureScreen() {
const { appID } = useParams() as { appID: string };
// Data hooks
const form = useAppConfigForm({ appID, constructFormState, constructConfig });
const featureConfig = useAppFeatureConfigQuery(appID);
// Tab navigation (if the screen has tabs)
const { selectedKey, onLinkClick, onChangeKey } =
usePivotNavigation<MyTab>(["overview", "settings"]);
// Loading / error guards — render nothing else until resolved
if (form.isLoading || featureConfig.isLoading) {
return <ShowLoading />;
}
if (form.loadError) {
return <ShowError error={form.loadError} onRetry={form.reload} />;
}
if (featureConfig.loadError) {
return <ShowError error={featureConfig.loadError} onRetry={featureConfig.reload} />;
}
return (
<FormContainer form={form} canSave={isModifiable} ...>
<MyFeatureContent
form={form}
featureConfig={featureConfig.effectiveFeatureConfig?.my_feature}
selectedKey={selectedKey}
onLinkClick={onLinkClick}
onChangeKey={onChangeKey}
/>
</FormContainer>
);
};
Receives the form model and config as props. Handles user interactions and renders the full UI. Callbacks (e.g., onChange handlers) are defined here using useCallback.
interface MyFeatureContentProps {
form: AppConfigFormModel<FormState>;
featureConfig?: MyFeatureConfig;
selectedKey: MyTab;
onLinkClick: (item?: PivotItem) => void;
onChangeKey: (key: MyTab) => void;
}
const MyFeatureContent: React.VFC<MyFeatureContentProps> =
function MyFeatureContent(props) {
const { form, featureConfig, selectedKey, onLinkClick, onChangeKey } = props;
const { state, setState } = form;
const onSomeFieldChange = useCallback(
(_event, value?: string) => {
setState((current) => ({ ...current, someField: value ?? "" }));
},
[setState]
);
return (
<ScreenContent layout="list">
<ScreenTitle className={styles.widget}>...</ScreenTitle>
{/* ... */}
</ScreenContent>
);
};
Define constructFormState and constructConfig as plain functions (not inside components) near the top of the file, after the FormState interface:
interface FormState {
enabled: boolean;
someField: string;
}
function constructFormState(config: PortalAPIAppConfig): FormState {
return {
enabled: config.my_feature?.enabled ?? false,
someField: config.my_feature?.some_field ?? "",
};
}
function constructConfig(
config: PortalAPIAppConfig,
_initialState: FormState,
currentState: FormState,
_effectiveConfig: PortalAPIAppConfig
): PortalAPIAppConfig {
return produce(config, (draft) => {
draft.my_feature ??= {};
draft.my_feature.enabled = currentState.enabled;
draft.my_feature.some_field = currentState.someField || undefined;
clearEmptyObject(draft);
});
}
After creating the screen file, register it as a lazy-loaded route in portal/src/AppRoot.tsx.
const MyFeatureScreen = lazy(
async () => import("./screens/my-feature/MyFeatureScreen")
);
Find the appropriate <Route> section and add:
<Route path="my-feature" element={<MyFeatureScreen />} />
When a component grows large, extract sub-components into portal/src/components/<feature>/. Good candidates for extraction:
.tsx and .module.css.form or setState into sub-components. Instead, pass the specific values and typed callback functions they need.showAll for a list toggle), define it inside that sub-component — do not lift it up.isActive prop — it is simply not rendered when inactive, so queries inside it should use skip: !enabled (or similar) rather than checking tab state.Before (all in one component):
{selectedKey === "overview" ? (
<section>
{/* 200 lines of overview JSX */}
</section>
) : null}
After:
// In components/my-feature/MyFeatureOverviewTab.tsx
export interface MyFeatureOverviewTabProps {
enabled: boolean;
onChangeToSettings: () => void;
}
const MyFeatureOverviewTab: React.VFC<MyFeatureOverviewTabProps> = ...
// In the screen:
{selectedKey === "overview" ? (
<MyFeatureOverviewTab enabled={state.enabled} onChangeToSettings={() => onChangeKey("settings")} />
) : null}
The portal has two separate GraphQL APIs with separate code-generation configs:
| API | Schema | Query files | Generated output | Use for |
|---|---|---|---|---|
| Admin API | portal/src/graphql/adminapi/schema.graphql | portal/src/graphql/adminapi/query/*.graphql | *.generated.ts next to the .graphql file | App-level data (users, audit logs, fraud protection, etc.) |
| Portal API | portal/src/graphql/portal/schema.graphql | portal/src/graphql/portal/query/*.graphql | *.generated.ts next to the .graphql file | Portal-level data (subscriptions, feature config, app list, etc.) |
.graphql file in the appropriate query/ directory:# portal/src/graphql/adminapi/query/myFeatureQuery.graphql
query myFeatureQuery($appID: ID!, $rangeFrom: DateTime, $rangeTo: DateTime) {
myFeature(appID: $appID, rangeFrom: $rangeFrom, rangeTo: $rangeTo) {
totalCount
someField
nestedItems {
id
value
}
}
}
portal/:npm run gentype
This produces a myFeatureQuery.generated.ts file next to the .graphql file containing typed hooks, variables types, and result types.
Always import from the .generated.ts file, never inline gql in .ts/.tsx files.
// ✅ Correct
import { useMyFeatureQueryQuery } from "../../graphql/adminapi/query/myFeatureQuery.generated";
// ❌ Wrong — do not write inline gql documents
const MY_QUERY = gql`query myFeatureQuery { ... }`;
The generated hook is a standard Apollo hook. Use Apollo options directly:
const {
data,
loading,
error,
refetch,
} = useMyFeatureQueryQuery({
skip: !enabled,
variables: { appID, rangeFrom, rangeTo },
});
const result = data?.myFeature ?? null;
const onRetry = useCallback(() => {
void refetch();
}, [refetch]);
appID.useAppFeatureConfigQuery use this API.Use Tailwind utility classes via @apply. Never write raw CSS values when a Tailwind class exists.
/* ✅ Good */
.metricCard {
@apply flex flex-col rounded-lg border border-[#edebe9] bg-white px-[14px] py-4;
}
/* ❌ Avoid raw CSS when Tailwind equivalent exists */
.metricCard {
display: flex;
flex-direction: column;
border-radius: 0.5rem;
padding: 1rem;
}
For icon/badge variants that share a base style, group them in one selector then override per-variant:
/* Shared base */
.metricIcon,
.metricIconSuccess,
.metricIconWarning,
.metricIconBlocked {
@apply inline-flex h-8 w-8 flex-none items-center justify-center rounded-md text-[16px];
}
/* Per-variant color */
.metricIcon { @apply bg-[#edf6ff] text-[#176df3]; }
.metricIconSuccess { @apply bg-[#eef6ef] text-[#16a34a]; }
.metricIconWarning { @apply bg-[#fff4ce] text-[#d97706]; }
.metricIconBlocked { @apply bg-[#fef2f2] text-[#dc2626]; }
Map variants via a lookup object in the component rather than building class strings dynamically:
const iconVariantClass: Record<MetricIconVariant, string> = {
default: styles.metricIcon,
success: styles.metricIconSuccess,
warning: styles.metricIconWarning,
blocked: styles.metricIconBlocked,
};
After any changes to portal files, always run both checks and fix all errors before finishing:
cd portal
npm run typecheck
npm run eslint
Common issues to watch for:
@typescript-eslint/no-unnecessary-condition: A condition that is always true/false because TypeScript can prove it at the call site. Usually caused by checking a prop value that is already guaranteed by context (e.g., isActive={selectedKey === "overview"} inside a branch where selectedKey === "overview" is already true).useCallback/useMemo: Inline arrow functions passed as props to components should be wrapped in useCallback to avoid unnecessary re-renders.| Task | Command |
|---|---|
Generate TypeScript types from .graphql files | cd portal && npm run gentype |
| Type-check all TypeScript | cd portal && npm run typecheck |
| Lint all TypeScript/TSX | cd portal && npm run eslint |
| Auto-fix lint issues | cd portal && npm run eslint:format |
| Check CSS | cd portal && npm run stylelint |