mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-09 22:33:37 +00:00
feat(install): redesign web wizard and scope dev token to PAT installs
Redesign the install wizard for clarity: - swap the sidebar layout for a centered column and a horizontal stepper - make completed/current stepper entries clickable links - reorder steps so Server URL precedes LLMs - use env-var placeholders (ANTHROPIC_API_KEY, etc.) with per-provider "Where do I get this?" disclosures - replace the readonly "Validated username" input with a success pill - drop the GitHub App name field (GitHub confirms the name anyway) - re-label the GitHub App option and split review rows by strategy - add a copy action to the Server URL on the review screen Scope the dev token to PAT installs: - only generate the dev token, write its files, and set FABRO_DEV_TOKEN inside the GithubInstallState::Token arm - mark dev_token optional on InstallFinishResponse in the OpenAPI spec - hide the Development token card on /install/finishing when absent - add app_install_finish_omits_dev_token_and_does_not_write_it test Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b849738a5b
commit
54ddaa2cee
10 changed files with 2947 additions and 2398 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -2,16 +2,28 @@ export const INSTALL_PROVIDERS = [
|
|||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
hint: "Claude API key.",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
keyHelp: {
|
||||
url: "https://console.anthropic.com/settings/keys",
|
||||
text: "Create one in the Anthropic Console under Settings → API keys.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
hint: "Responses API key.",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
keyHelp: {
|
||||
url: "https://platform.openai.com/api-keys",
|
||||
text: "Create one in the OpenAI platform under API keys.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
label: "Gemini",
|
||||
hint: "Google AI Studio API key.",
|
||||
envVar: "GEMINI_API_KEY",
|
||||
keyHelp: {
|
||||
url: "https://aistudio.google.com/apikey",
|
||||
text: "Create one in Google AI Studio.",
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
|
|
|||
|
|
@ -2170,7 +2170,6 @@ components:
|
|||
required:
|
||||
- status
|
||||
- restart_url
|
||||
- dev_token
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
|
|
@ -2180,6 +2179,10 @@ components:
|
|||
format: uri
|
||||
dev_token:
|
||||
type: string
|
||||
description: |
|
||||
Dev token used to bootstrap login. Only included when the operator
|
||||
chose the personal access token flow; GitHub App installs rely on
|
||||
OAuth and do not receive a dev token.
|
||||
|
||||
# ── Pagination ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -815,6 +815,7 @@ async fn post_install_finish(
|
|||
}
|
||||
|
||||
let mut server_env_secrets = Vec::new();
|
||||
let mut dev_token: Option<String> = None;
|
||||
match github {
|
||||
GithubInstallState::Token(github) => {
|
||||
if let Err(err) = write_token_settings(&mut settings_doc) {
|
||||
|
|
@ -826,6 +827,25 @@ async fn post_install_finish(
|
|||
secret_type: VaultSecretType::Environment,
|
||||
description: None,
|
||||
});
|
||||
let home = state.home.clone().unwrap_or_else(Home::from_env);
|
||||
let token = match dev_token::load_or_create_dev_token(&home.dev_token_path()) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return install_error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
err.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Err(err) = dev_token::write_dev_token(
|
||||
&Storage::new(state.storage_dir.as_ref())
|
||||
.server_state()
|
||||
.dev_token_path(),
|
||||
&token,
|
||||
) {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
dev_token = Some(token);
|
||||
}
|
||||
GithubInstallState::App(github) => {
|
||||
if let Err(err) = write_github_app_settings(
|
||||
|
|
@ -862,22 +882,6 @@ async fn post_install_finish(
|
|||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
};
|
||||
let home = state.home.clone().unwrap_or_else(Home::from_env);
|
||||
let dev_token = match dev_token::load_or_create_dev_token(&home.dev_token_path()) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
};
|
||||
if let Err(err) = dev_token::write_dev_token(
|
||||
&Storage::new(state.storage_dir.as_ref())
|
||||
.server_state()
|
||||
.dev_token_path(),
|
||||
&dev_token,
|
||||
) {
|
||||
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
|
||||
}
|
||||
|
||||
server_env_secrets.extend([
|
||||
(
|
||||
"FABRO_JWT_PRIVATE_KEY".to_string(),
|
||||
|
|
@ -888,8 +892,10 @@ async fn post_install_finish(
|
|||
BASE64_STANDARD.encode(jwt_public_pem.as_bytes()),
|
||||
),
|
||||
("SESSION_SECRET".to_string(), session_secret),
|
||||
("FABRO_DEV_TOKEN".to_string(), dev_token.clone()),
|
||||
]);
|
||||
if let Some(token) = dev_token.as_ref() {
|
||||
server_env_secrets.push(("FABRO_DEV_TOKEN".to_string(), token.clone()));
|
||||
}
|
||||
|
||||
let previous_settings = std::fs::read_to_string(state.config_path.as_ref()).ok();
|
||||
|
||||
|
|
@ -944,15 +950,14 @@ async fn post_install_finish(
|
|||
}
|
||||
finish_guard.disarm();
|
||||
|
||||
(
|
||||
StatusCode::ACCEPTED,
|
||||
Json(serde_json::json!({
|
||||
"status": "completing",
|
||||
"restart_url": server.canonical_url,
|
||||
"dev_token": dev_token,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
let mut body = serde_json::json!({
|
||||
"status": "completing",
|
||||
"restart_url": server.canonical_url,
|
||||
});
|
||||
if let Some(token) = dev_token {
|
||||
body["dev_token"] = serde_json::Value::String(token);
|
||||
}
|
||||
(StatusCode::ACCEPTED, Json(body)).into_response()
|
||||
}
|
||||
|
||||
async fn render_install_shell(headers: HeaderMap, uri: OriginalUri) -> Response {
|
||||
|
|
|
|||
|
|
@ -381,6 +381,150 @@ async fn token_install_finish_persists_settings_env_and_vault() {
|
|||
assert_eq!(vault.get("GITHUB_TOKEN"), Some("ghp_test_token"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_install_finish_omits_dev_token_and_does_not_write_it() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let home_root = tempfile::tempdir().unwrap();
|
||||
let home = Home::new(home_root.path().join(".fabro"));
|
||||
let config_path = temp_dir.path().join("settings.toml");
|
||||
let github_mock = MockServer::start_async().await;
|
||||
github_mock
|
||||
.mock_async(|when, then| {
|
||||
when.method("POST")
|
||||
.path("/app-manifests/stub-code/conversions");
|
||||
then.status(200)
|
||||
.header("content-type", "application/json")
|
||||
.body(
|
||||
r#"{
|
||||
"id": 42,
|
||||
"slug": "fabro-test-app",
|
||||
"client_id": "Iv1.test-client-id",
|
||||
"client_secret": "test-client-secret",
|
||||
"webhook_secret": "test-webhook-secret",
|
||||
"pem": "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n"
|
||||
}"#,
|
||||
);
|
||||
})
|
||||
.await;
|
||||
let app = build_install_router(
|
||||
InstallAppState::for_test_with_paths("test-install-token", temp_dir.path(), &config_path)
|
||||
.with_home(home.clone())
|
||||
.with_github_api_base_url(github_mock.url("")),
|
||||
);
|
||||
|
||||
let llm_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/llm")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"providers":[{"provider":"anthropic","api_key":"anthropic-test-key"}]}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(llm_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let server_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("PUT")
|
||||
.uri("/install/server")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"canonical_url":"https://fabro.example.com"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(server_response.status(), StatusCode::NO_CONTENT);
|
||||
|
||||
let manifest_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/github/app/manifest")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"owner":{"kind":"personal"},"app_name":"Fabro Test","allowed_username":"octocat"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(manifest_response.status(), StatusCode::OK);
|
||||
let manifest_body = body_json(manifest_response.into_body()).await;
|
||||
let redirect_url = manifest_body["manifest"]["redirect_url"]
|
||||
.as_str()
|
||||
.expect("redirect_url should be present");
|
||||
let redirect_uri = fabro_http::Url::parse(redirect_url).unwrap();
|
||||
let state = redirect_uri
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "state")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
.expect("state should be embedded in redirect_url");
|
||||
|
||||
let callback_response = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("GET")
|
||||
.uri(format!(
|
||||
"/install/github/app/redirect?code=stub-code&state={state}"
|
||||
))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(callback_response.status(), StatusCode::FOUND);
|
||||
|
||||
let finish_response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/install/finish")
|
||||
.header("authorization", "Bearer test-install-token")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(finish_response.status(), StatusCode::ACCEPTED);
|
||||
let finish_body = body_json(finish_response.into_body()).await;
|
||||
assert_eq!(finish_body["status"], "completing");
|
||||
assert_eq!(finish_body["restart_url"], "https://fabro.example.com");
|
||||
assert!(
|
||||
finish_body.get("dev_token").is_none(),
|
||||
"App installs must not expose a dev token"
|
||||
);
|
||||
|
||||
let server_env =
|
||||
std::fs::read_to_string(Storage::new(temp_dir.path()).server_state().env_path()).unwrap();
|
||||
assert!(!server_env.contains("FABRO_DEV_TOKEN="));
|
||||
|
||||
assert!(
|
||||
!home.dev_token_path().exists(),
|
||||
"home dev token file should not be created for App installs"
|
||||
);
|
||||
assert!(
|
||||
!Storage::new(temp_dir.path())
|
||||
.server_state()
|
||||
.dev_token_path()
|
||||
.exists(),
|
||||
"storage dev token file should not be created for App installs"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_install_finish_invokes_shutdown_callback_after_accepting() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
1969
lib/crates/fabro-spa/assets/assets/entry-70cq40fr.js
generated
1969
lib/crates/fabro-spa/assets/assets/entry-70cq40fr.js
generated
File diff suppressed because one or more lines are too long
1969
lib/crates/fabro-spa/assets/assets/entry-rzgjexka.js
generated
Normal file
1969
lib/crates/fabro-spa/assets/assets/entry-rzgjexka.js
generated
Normal file
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -61,7 +61,7 @@
|
|||
<script type="module" src="/assets/chunk-sadshphz.js"></script>
|
||||
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
|
||||
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
|
||||
<script type="module" src="/assets/entry-70cq40fr.js"></script>
|
||||
<script type="module" src="/assets/entry-rzgjexka.js"></script>
|
||||
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
|
||||
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
|
||||
<script type="module" src="/assets/chunk-9t57pdty.js"></script>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@
|
|||
export interface InstallFinishResponse {
|
||||
'status': InstallFinishResponseStatusEnum;
|
||||
'restart_url': string;
|
||||
'dev_token': string;
|
||||
/**
|
||||
* Dev token used to bootstrap login. Only included when the operator chose the personal access token flow; GitHub App installs rely on OAuth and do not receive a dev token.
|
||||
*/
|
||||
'dev_token'?: string;
|
||||
}
|
||||
|
||||
export const InstallFinishResponseStatusEnum = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue