{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "provider-gitlab",
  "title": "GitLab CI Provider",
  "description": "A self-contained form component that emits an OidcPolicy for a GitLab CI caller: instance (gitlab.com or self-hosted), group and project, job-requested audience, and branch/tag/environment scoping.",
  "registryDependencies": [
    "https://ui.shadcn.com/oidc/r/policy.json",
    "https://ui.shadcn.com/oidc/r/claims.json",
    "button",
    "field",
    "input",
    "select"
  ],
  "files": [
    {
      "path": "registry/oidc/components/provider-gitlab.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Field, FieldDescription, FieldLabel } from \"@/components/ui/field\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Select,\n  SelectContent,\n  SelectGroup,\n  SelectItem,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport {\n  ClaimChip,\n  ClaimsList,\n  claimRowsFromPolicy,\n  claimsListValid,\n  mergeClaimRows,\n  type ClaimRow,\n} from \"@/components/oidc/claims\"\nimport type { OidcPolicy } from \"@/lib/oidc/policy\"\n\nfunction GitlabIcon({ ...props }: React.ComponentProps<\"svg\">) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" {...props}>\n      <path d=\"m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z\" />\n    </svg>\n  )\n}\n\ntype Scope = \"any\" | \"branch\" | \"tag\" | \"environment\"\n\ntype Fields = {\n  issuer: string\n  namespace: string\n  project: string\n  audience: string\n  scope: Scope\n  scopeValue: string\n  extraClaims: ClaimRow[]\n}\n\nconst SCOPE_OPTIONS: { label: string; value: Scope }[] = [\n  { label: \"Any branch or tag\", value: \"any\" },\n  { label: \"Branch\", value: \"branch\" },\n  { label: \"Tag\", value: \"tag\" },\n  { label: \"Environment\", value: \"environment\" },\n]\n\nconst DEFAULTS: Fields = {\n  issuer: \"https://gitlab.com\",\n  namespace: \"\",\n  project: \"\",\n  audience: \"\",\n  scope: \"any\",\n  scopeValue: \"\",\n  extraClaims: [],\n}\n\nconst FIRST_CLASS_CLAIMS = [\n  \"aud\",\n  \"project_path\",\n  \"ref\",\n  \"ref_type\",\n  \"environment\",\n]\n\n// Turns a pasted GitLab link into a field patch. Self-hosted hosts count\n// only when the /-/ marker makes the URL unmistakably GitLab.\nfunction decodeSegment(segment: string) {\n  try {\n    return decodeURIComponent(segment)\n  } catch {\n    return null\n  }\n}\n\nfunction parsePastedProject(text: string) {\n  const input = text.trim()\n  const ssh = input.match(/^git@([\\w.-]+):(.+?)(?:\\.git)?$/)\n  const web = input.match(\n    /^(?:https?:\\/\\/)?([\\w.-]+(?::\\d+)?)\\/([^?#]+?)(?:[?#].*)?$/\n  )\n  const host = ssh?.[1] ?? web?.[1]\n  const fullPath = ssh?.[2] ?? web?.[2]\n  if (!host || !fullPath) {\n    return null\n  }\n  if ([\"github.com\", \"www.github.com\", \"bitbucket.org\"].includes(host)) {\n    return null\n  }\n  if (host !== \"gitlab.com\" && !fullPath.includes(\"/-/\")) {\n    return null\n  }\n  const cleaned = fullPath.replace(/\\.git$/, \"\")\n  const markerIndex = cleaned.indexOf(\"/-/\")\n  const projectPath =\n    markerIndex === -1 ? cleaned : cleaned.slice(0, markerIndex)\n  const marker = markerIndex === -1 ? \"\" : cleaned.slice(markerIndex + 3)\n  const segments = projectPath.split(\"/\").filter(Boolean)\n  if (segments.length < 2) {\n    return null\n  }\n  const patch = {\n    issuer: `https://${host}`,\n    namespace: segments.slice(0, -1).join(\"/\"),\n    project: segments[segments.length - 1],\n  }\n  const tag = marker.match(/^tags\\/(.+?)\\/?$/)\n  if (tag) {\n    const scopeValue = decodeSegment(tag[1])\n    if (scopeValue !== null) {\n      return { ...patch, scope: \"tag\" as const, scopeValue }\n    }\n  }\n  const branch = marker.match(/^tree\\/(.+?)\\/?$/)\n  if (branch) {\n    const scopeValue = decodeSegment(branch[1])\n    if (scopeValue !== null) {\n      return { ...patch, scope: \"branch\" as const, scopeValue }\n    }\n  }\n  return patch\n}\n\n// GitLab refs carry no refs/heads prefix; branch vs tag is the ref_type\n// claim. The audience is whatever the job requests in its id_tokens config —\n// there is no platform default.\nfunction compile(fields: Fields) {\n  const claims: Record<string, string[]> = {}\n  if (fields.audience) {\n    claims.aud = [fields.audience]\n  }\n  if (fields.namespace && fields.project) {\n    claims.project_path = [`${fields.namespace}/${fields.project}`]\n  }\n  if (fields.scopeValue) {\n    if (fields.scope === \"branch\") {\n      claims.ref = [fields.scopeValue]\n      claims.ref_type = [\"branch\"]\n    } else if (fields.scope === \"tag\") {\n      claims.ref = [fields.scopeValue]\n      claims.ref_type = [\"tag\"]\n    } else if (fields.scope === \"environment\") {\n      claims.environment = [fields.scopeValue]\n    }\n  }\n  mergeClaimRows(claims, fields.extraClaims, FIRST_CLASS_CLAIMS)\n  return { issuer: fields.issuer, claims }\n}\n\nfunction parse(policy: OidcPolicy) {\n  if (!policy.issuer.startsWith(\"https://\")) {\n    return null\n  }\n  if (\n    FIRST_CLASS_CLAIMS.some((name) => (policy.claims[name]?.length ?? 0) > 1)\n  ) {\n    return null\n  }\n  const extraClaims = claimRowsFromPolicy(policy.claims, FIRST_CLASS_CLAIMS)\n  if (extraClaims === null) {\n    return null\n  }\n\n  const [projectPath = \"\"] = policy.claims.project_path ?? []\n  const segments = projectPath.split(\"/\")\n  const project = segments.pop() ?? \"\"\n  const namespace = segments.join(\"/\")\n  if (!namespace || !project) {\n    return null\n  }\n\n  const [ref] = policy.claims.ref ?? []\n  const [refType] = policy.claims.ref_type ?? []\n  const [environment] = policy.claims.environment ?? []\n  if (ref && environment) {\n    return null\n  }\n  if (ref && refType !== \"branch\" && refType !== \"tag\") {\n    return null\n  }\n  if (!ref && refType) {\n    return null\n  }\n\n  let scope: Scope = \"any\"\n  let scopeValue = \"\"\n  if (ref && refType === \"branch\") {\n    scope = \"branch\"\n    scopeValue = ref\n  } else if (ref && refType === \"tag\") {\n    scope = \"tag\"\n    scopeValue = ref\n  } else if (environment) {\n    scope = \"environment\"\n    scopeValue = environment\n  }\n\n  return {\n    issuer: policy.issuer,\n    namespace,\n    project,\n    audience: policy.claims.aud?.[0] ?? \"\",\n    scope,\n    scopeValue,\n    extraClaims,\n  }\n}\n\n/** True when this form can represent the policy — for routing an edit. */\nfunction matchesGitlabPolicy(policy: OidcPolicy) {\n  return parse(policy) !== null\n}\n\nfunction GitlabPolicyForm({\n  defaultValue,\n  onChange,\n  onSubmit,\n  className,\n}: {\n  defaultValue?: OidcPolicy\n  onChange?: (policy: OidcPolicy) => void\n  onSubmit: (policy: OidcPolicy) => void\n  className?: string\n}) {\n  const [fields, setFields] = React.useState<Fields>(() =>\n    defaultValue ? (parse(defaultValue) ?? DEFAULTS) : DEFAULTS\n  )\n\n  const apply = (next: Fields) => {\n    setFields(next)\n    const policy = compile(next)\n    onChange?.(\n      defaultValue?.label ? { ...policy, label: defaultValue.label } : policy\n    )\n  }\n\n  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {\n    event.preventDefault()\n    if (!(\n      Boolean(\n        fields.issuer && fields.namespace && fields.project && fields.audience\n      ) && claimsListValid(fields.extraClaims, FIRST_CLASS_CLAIMS)\n    )) {\n      return\n    }\n    const policy = compile(fields)\n    onSubmit(\n      defaultValue?.label ? { ...policy, label: defaultValue.label } : policy\n    )\n  }\n\n  return (\n    <form\n      data-slot=\"gitlab-policy-form\"\n      className={cn(\"flex flex-col gap-4\", className)}\n      onSubmit={handleSubmit}\n    >\n      <Field>\n        <FieldLabel htmlFor=\"gl-issuer\" className=\"w-full\">\n          Instance <ClaimChip>iss</ClaimChip>\n        </FieldLabel>\n        <Input\n          id=\"gl-issuer\"\n          value={fields.issuer}\n          placeholder=\"https://gitlab.com\"\n          onChange={(event) =>\n            apply({ ...fields, issuer: event.target.value.trim() })\n          }\n        />\n        <FieldDescription>\n          Keep https://gitlab.com for the hosted service, or enter your\n          self-hosted instance URL.\n        </FieldDescription>\n      </Field>\n      <Field>\n        <FieldLabel htmlFor=\"gl-namespace\" className=\"w-full\">\n          Group or username <ClaimChip>project_path</ClaimChip>\n        </FieldLabel>\n        <Input\n          id=\"gl-namespace\"\n          value={fields.namespace}\n          placeholder=\"acme\"\n          onChange={(event) =>\n            apply({ ...fields, namespace: event.target.value.trim() })\n          }\n          onPaste={(event) => {\n            const patch = parsePastedProject(\n              event.clipboardData.getData(\"text\")\n            )\n            if (!patch) {\n              return\n            }\n            event.preventDefault()\n            apply({ ...fields, ...patch })\n          }}\n        />\n        <FieldDescription>\n          Subgroups work too: acme/platform. Pasting a GitLab link fills the\n          form.\n        </FieldDescription>\n      </Field>\n      <Field>\n        <FieldLabel htmlFor=\"gl-project\" className=\"w-full\">\n          Project <ClaimChip>project_path</ClaimChip>\n        </FieldLabel>\n        <Input\n          id=\"gl-project\"\n          value={fields.project}\n          placeholder=\"web\"\n          onChange={(event) =>\n            apply({ ...fields, project: event.target.value.trim() })\n          }\n        />\n      </Field>\n      <Field>\n        <FieldLabel htmlFor=\"gl-audience\" className=\"w-full\">\n          Audience <ClaimChip>aud</ClaimChip>\n        </FieldLabel>\n        <Input\n          id=\"gl-audience\"\n          value={fields.audience}\n          placeholder=\"https://your-app.example.com\"\n          onChange={(event) =>\n            apply({ ...fields, audience: event.target.value.trim() })\n          }\n        />\n        <FieldDescription>\n          The aud your job requests in its id_tokens config. Pick a value unique\n          to your app.\n        </FieldDescription>\n      </Field>\n      <Field>\n        <FieldLabel htmlFor=\"gl-scope\">Scope by</FieldLabel>\n        <Select\n          items={SCOPE_OPTIONS}\n          value={fields.scope}\n          onValueChange={(next) => {\n            const next2 = {\n              ...fields,\n              scope: (next ?? DEFAULTS.scope) as Scope,\n            }\n            next2.scopeValue = \"\"\n            apply(next2)\n          }}\n        >\n          <SelectTrigger id=\"gl-scope\" className=\"w-full\">\n            <SelectValue />\n          </SelectTrigger>\n          <SelectContent>\n            <SelectGroup>\n              {SCOPE_OPTIONS.map((option) => (\n                <SelectItem key={option.value} value={option.value}>\n                  {option.label}\n                </SelectItem>\n              ))}\n            </SelectGroup>\n          </SelectContent>\n        </Select>\n      </Field>\n      {fields.scope !== \"any\" ? (\n        <Field>\n          <FieldLabel htmlFor=\"gl-scopeValue\" className=\"w-full\">\n            {fields.scope === \"branch\"\n              ? \"Branch name\"\n              : fields.scope === \"tag\"\n                ? \"Tag name\"\n                : \"Environment name\"}{\" \"}\n            <ClaimChip>\n              {fields.scope === \"environment\" ? \"environment\" : \"ref\"}\n            </ClaimChip>\n          </FieldLabel>\n          <Input\n            id=\"gl-scopeValue\"\n            value={fields.scopeValue}\n            placeholder={fields.scope === \"environment\" ? \"production\" : \"main\"}\n            onChange={(event) =>\n              apply({ ...fields, scopeValue: event.target.value })\n            }\n          />\n        </Field>\n      ) : null}\n      <ClaimsList\n        value={fields.extraClaims}\n        onChange={(rows) => apply({ ...fields, extraClaims: rows })}\n        reserved={FIRST_CLASS_CLAIMS}\n      />\n      <Button type=\"submit\" className=\"self-start\">\n        Save\n      </Button>\n    </form>\n  )\n}\n\nexport { GitlabIcon, GitlabPolicyForm, matchesGitlabPolicy }\n",
      "type": "registry:component",
      "target": "@components/oidc/provider-gitlab.tsx"
    }
  ],
  "meta": {
    "tagline": "Trust jobs from a project's pipelines"
  },
  "categories": [
    "auth",
    "forms"
  ],
  "type": "registry:component"
}