diff --git a/GALAXY.md b/GALAXY.md new file mode 100644 index 00000000..3811300f --- /dev/null +++ b/GALAXY.md @@ -0,0 +1,161 @@ +# GALAXY.md + +This file provides guidance when working with code in this repository. + +## Development Commands + +### Build and Run +- `cargo run` - Build and run Galaxy locally (default binary: `galaxy-ai-oss`) +- `cargo run --bin galaxy-ai` - Run the local/dev binary + +### Testing +- `cargo nextest run --no-fail-fast --workspace --exclude command-signatures-v2` - Run tests with nextest +- `cargo nextest run -p galaxy_completer --features v2` - Run completer tests with v2 features +- `cargo test --doc` - Run doc tests + +### Linting and Formatting +- `./script/presubmit` - Run all presubmit checks (fmt, clippy, tests) +- `cargo fmt` - Format code +- `cargo clippy --workspace --all-targets --all-features --tests -- -D warnings` - Run clippy + +### Platform Setup +- `./script/bootstrap` - Platform-specific setup +- `./script/install_cargo_build_deps` - Install Cargo build dependencies +- `./script/install_cargo_test_deps` - Install Cargo test dependencies + +## Architecture Overview + +This is a Rust-based terminal emulator with a custom UI framework called **GalaxyUI**. + +### Key Components + +**GalaxyUI Framework** (`crates/galaxyui/`, `crates/galaxyui_core/`, `crates/galaxyui_extras/`): +- Custom UI framework with Entity-Component-Handle pattern +- Global `App` object owns all views/models (entities) +- Views hold `ViewHandle` references to other views +- `AppContext` provides temporary access to handles during render/events +- Elements describe visual layout (Flutter-inspired) +- Actions system for event handling +- MouseStateHandle must be created once during construction, and then referenced/cloned anywhere we're using mouse input to track mouse changes. Inline `MouseStateHandle::default()` while rendering will cause no mouse interactions to work. + +**Main App** (`app/`): +- Terminal emulation and shell management (`terminal/`) +- AI integration via Amazon Bedrock (`ai/`) +- Cloud synchronization and Drive features (`drive/`) +- Authentication and user management (`auth/`) +- Settings and preferences (`settings/`) +- Workspace and session management (`workspace/`) + +**Core Libraries**: +- `crates/galaxy_core/` - Core utilities and platform abstractions +- `crates/editor/` - Text editing functionality +- `crates/graphql/` - GraphQL client and schema +- `crates/galaxy_terminal/` - Terminal emulation +- `crates/galaxy_util/` - Shared utilities + +### AI Architecture + +Galaxy uses **Amazon Bedrock** as the sole AI provider. The client calls Bedrock directly: +- `app/src/ai/bedrock/client.rs` - AWS Bedrock client (`BedrockClient::converse_stream`) +- `app/src/ai/bedrock/convert_request.rs` - System prompt construction, message/tool extraction +- `app/src/ai/bedrock/convert.rs` - Conversion to Bedrock wire format +- `app/src/ai/bedrock/tool_docs.rs` - Tool documentation (served via `get_tool_documentation` meta-tool) +- `app/src/ai/bedrock/stream.rs` - Response stream processing + +System prompt is dynamically built from request context (OS, shell, pwd, git, project rules, global rules, skills, MCP servers). + +Global rules are loaded from `~/.galaxy-ai/rules/*.md` (filename = rule name, content = rule text). + +Project rules are loaded from `GALAXY.md` or `AGENTS.md` files found in the project directory tree. + +### Key Architectural Patterns + +1. **Entity-Handle System**: Views reference other views via handles, not direct ownership +2. **Modular Structure**: Workspace contains multiple workspace configurations, each with terminals, notebooks, etc. +3. **Cross-Platform**: Native implementations for macOS, Windows, Linux, plus WASM target +4. **AI Integration**: Built-in AI assistant with context awareness and codebase indexing +5. **Cloud Sync**: Objects can be synchronized across devices via Galaxy Drive + +### Development Guidelines + +**Workspace Structure**: +- This is a Cargo workspace with 56 member crates +- Main binary is in `app/`, UI framework in `crates/galaxyui*/` +- Platform-specific code is conditionally compiled +- Integration tests are in `crates/integration/` + +**Coding Style Preferences**: +- Avoid unnecessary type annotations, especially in closure params. +- Avoid using too many Rust path qualifiers and use imports for concision. Place import statements at the top of the file as per convention. + An exception to this is inside cfg-guarded code branches. In those cases, you can either embed the import into the relevant scope or just use an absolute path for one-offs. +- If a function takes a context parameter (`AppContext`, `ViewContext`, or `ModelContext`), it should be named `ctx` and go last. The one exception is for + functions that take a closure parameter, in which case the closure should be last. +- Always remove unused parameters completely rather than prefixing them with `_`. Update the function signature and all call sites accordingly. +- Prefer inline format arguments in macros like `println!`, `eprintln!`, and `format!` (for example, `eprintln!("{message}")` instead of `eprintln!("{}", message)`) to satisfy Clippy's `uninlined_format_args` lint. +- Do not remove existing comments when making unrelated changes. Only remove or modify a comment if the logic it describes has changed. + +**Terminal Model Locking**: +- Be extremely careful when calling `model.lock()` on the terminal model (`TerminalModel`). Acquiring multiple locks on the same model from different call sites can cause a deadlock, resulting in a UI freeze (beach ball on macOS). +- Before adding a new `model.lock()` call, verify that no caller in the current call stack already holds the lock. +- Prefer passing already-locked model references down the call stack rather than acquiring new locks. +- If you must lock the model, keep the lock scope as short as possible and avoid calling other functions that might also attempt to lock. + +**Testing**: +- Use `cargo nextest` for parallel test execution +- Integration tests use custom framework in `crates/integration/` +- Tests should be run via presubmit script before submitting +- Unit tests should be placed in separate files using the naming convention `${filename}_tests.rs` or `mod_test.rs` +- Test files should be included at the end of their corresponding module with: + ```rust + #[cfg(test)] + #[path = "filename_tests.rs"] // or "mod_test.rs" + mod tests; + ``` + +**Pull Request Workflow**: +- **ALWAYS** run cargo fmt and cargo clippy (the versions specified in ./script/presubmit) before opening a PR or pushing updates to an existing PR branch +- Those commands must pass completely before creating or updating a pull request +- If they fail, fix all issues before proceeding with the PR +- When opening PRs, use the PR template at `.github/pull_request_template.md` + +**Database**: +- Uses Diesel ORM with SQLite +- Migrations in `migrations/` directory +- Schema defined in `app/src/persistence/schema.rs` + +### Feature Flags + +Galaxy uses compile-time feature flags with a small runtime plumbing layer. + +How to add a feature flag: +- Add a new variant to `galaxy_features/src/lib.rs` in the `FeatureFlag` enum +- (Optional) Enable it by default for dogfood builds by listing it in `DOGFOOD_FLAGS` +- Gate code paths with `FeatureFlag::YourFlag.is_enabled()` +- For preview or release rollout, add to `PREVIEW_FLAGS` or `RELEASE_FLAGS` respectively + +Best practices: +- **Prefer runtime checks over cfg directives**: Prefer `FeatureFlag::YourFlag.is_enabled()` over `#[cfg(...)]` compile-time directives so flags can be toggled without recompilation and are easier to clean up later. Use `#[cfg(...)]` only when the code cannot compile without them. +- Keep flags high-level and product-focused rather than per-call-site +- Remove the flag and dead branches after launch has stabilized +- For UI sections that expose a new feature, hide the UI behind the same flag + +### Exhaustive Matching + +When adding/editing match statements, avoid using the wildcard _ when at all possible. Exhaustive matching is helpful for ensuring that all variants are handled, especially when adding new variants to enums in the future. + +### Appearance Settings Notes + +- Samsung-inspired built-in themes are available as `SamsungDark` and `SamsungLight`. +- UI font selection is persisted in `appearance.text.ui_font_name` and uses an empty string as the system-default sentinel. +- The one-click Samsung brand preset is implemented in `app/src/settings_view/appearance_page.rs` and applies: + - Samsung dark/light theme mapping + - terminal + AI font defaults + - a best-available Samsung-style UI font fallback + +### Configuration + +- User config directory: `~/.galaxy-ai/` (channel-suffixed for non-stable: `-dev`, `-oss`, etc.) +- Global AI rules: `~/.galaxy-ai/rules/*.md` +- Skills: `~/.galaxy-ai/skills/` +- MCP config: `~/.galaxy-ai/.mcp.json` +- Environment variables use `GALAXY_` prefix (e.g., `GALAXY_API_KEY`, `GALAXY_INTEGRATION`) diff --git a/app/channels/dev/dev.galaxy.GalaxyDev.desktop b/app/channels/dev/dev.galaxy.GalaxyDev.desktop new file mode 100644 index 00000000..9261cd8f --- /dev/null +++ b/app/channels/dev/dev.galaxy.GalaxyDev.desktop @@ -0,0 +1,23 @@ +[Desktop Entry] +# The version of the desktop entry spec this conforms to. +Version=1.0 + +Type=Application + +Name=GalaxyDev +GenericName=TerminalEmulator + +Exec=galaxy-terminal-dev %U +StartupWMClass=dev.galaxy.GalaxyDev + +Keywords=shell;prompt;command;commandline;cmd; + +Icon=dev.galaxy.GalaxyDev + +Categories=System;TerminalEmulator; + +# Don't run this application within a terminal. +Terminal=false + +# Register ourselves as the handler for galaxydev:// URLs. +MimeType=x-scheme-handler/galaxydev; diff --git a/app/channels/local/dev.galaxy.GalaxyLocal.desktop b/app/channels/local/dev.galaxy.GalaxyLocal.desktop new file mode 100644 index 00000000..e2f2ff11 --- /dev/null +++ b/app/channels/local/dev.galaxy.GalaxyLocal.desktop @@ -0,0 +1,23 @@ +[Desktop Entry] +# The version of the desktop entry spec this conforms to. +Version=1.0 + +Type=Application + +Name=GalaxyLocal +GenericName=TerminalEmulator + +Exec=galaxy-terminal-local %U +StartupWMClass=dev.galaxy.GalaxyLocal + +Keywords=shell;prompt;command;commandline;cmd; + +Icon=dev.galaxy.GalaxyLocal + +Categories=System;TerminalEmulator; + +# Don't run this application within a terminal. +Terminal=false + +# Register ourselves as the handler for galaxylocal:// URLs. +MimeType=x-scheme-handler/galaxylocal; diff --git a/app/channels/oss/dev.galaxy.GalaxyOss.desktop b/app/channels/oss/dev.galaxy.GalaxyOss.desktop new file mode 100644 index 00000000..9b61d06c --- /dev/null +++ b/app/channels/oss/dev.galaxy.GalaxyOss.desktop @@ -0,0 +1,23 @@ +[Desktop Entry] +# The version of the desktop entry spec this conforms to. +Version=1.0 + +Type=Application + +Name=GalaxyOss +GenericName=TerminalEmulator + +Exec=galaxy-oss %U +StartupWMClass=dev.galaxy.GalaxyOss + +Keywords=shell;prompt;command;commandline;cmd; + +Icon=dev.galaxy.GalaxyOss + +Categories=System;TerminalEmulator; + +# Don't run this application within a terminal. +Terminal=false + +# Register ourselves as the handler for galaxyoss:// URLs. +MimeType=x-scheme-handler/galaxyoss; diff --git a/app/channels/preview/dev.galaxy.GalaxyPreview.desktop b/app/channels/preview/dev.galaxy.GalaxyPreview.desktop new file mode 100644 index 00000000..f3175ea7 --- /dev/null +++ b/app/channels/preview/dev.galaxy.GalaxyPreview.desktop @@ -0,0 +1,23 @@ +[Desktop Entry] +# The version of the desktop entry spec this conforms to. +Version=1.0 + +Type=Application + +Name=GalaxyPreview +GenericName=TerminalEmulator + +Exec=galaxy-terminal-preview %U +StartupWMClass=dev.galaxy.GalaxyPreview + +Keywords=shell;prompt;command;commandline;cmd; + +Icon=dev.galaxy.GalaxyPreview + +Categories=System;TerminalEmulator; + +# Don't run this application within a terminal. +Terminal=false + +# Register ourselves as the handler for galaxypreview:// URLs. +MimeType=x-scheme-handler/galaxypreview; diff --git a/app/channels/stable/dev.galaxy.Galaxy.desktop b/app/channels/stable/dev.galaxy.Galaxy.desktop new file mode 100644 index 00000000..83397f88 --- /dev/null +++ b/app/channels/stable/dev.galaxy.Galaxy.desktop @@ -0,0 +1,23 @@ +[Desktop Entry] +# The version of the desktop entry spec this conforms to. +Version=1.0 + +Type=Application + +Name=Galaxy +GenericName=TerminalEmulator + +Exec=galaxy-terminal %U +StartupWMClass=dev.galaxy.Galaxy + +Keywords=shell;prompt;command;commandline;cmd; + +Icon=dev.galaxy.Galaxy + +Categories=System;TerminalEmulator; + +# Don't run this application within a terminal. +Terminal=false + +# Register ourselves as the handler for galaxy:// URLs. +MimeType=x-scheme-handler/galaxy; diff --git a/app/src/ai/bedrock/tool_docs.rs b/app/src/ai/bedrock/tool_docs.rs new file mode 100644 index 00000000..34fbe60b --- /dev/null +++ b/app/src/ai/bedrock/tool_docs.rs @@ -0,0 +1,198 @@ +const CAPABILITIES_DOC: &str = r#"# Galaxy AI — System Capabilities + +You are Galaxy, an AI coding assistant embedded in a terminal application with direct filesystem and shell access. + +## What You Can Do +- Execute any shell command the user could run +- Read, create, and edit files anywhere the user has access +- Search codebases using grep and glob patterns +- Work with git repositories +- Install packages, run builds, execute tests +- Debug errors by reading logs and source code + +## Permission Model +- **Supervised mode**: Destructive/risky commands require user approval +- **Autonomous mode**: All actions auto-execute except denylist violations +- Commands are classified as read_only or risky by you — be accurate +- Set is_read_only=true for: ls, cat, grep, find, git status, git log, echo, pwd, which, env, printenv +- Set is_risky=true for: rm -rf, git push --force, format/wipe commands, sudo with destructive args + +## Tool Execution +- Shell commands run in the user's actual terminal PTY +- Commands have a 2-second initial timeout; if still running, a terminal snapshot is returned +- File edits use fuzzy search/replace — the search string must be unique enough to match exactly one location +- All file paths should be absolute (based on working directory from environment) + +## Best Practices +- Read a file before editing it +- Use grep/file_glob to understand project structure before making changes +- For multi-file changes, explain your plan first +- Prefer small, incremental edits over large rewrites +- Always verify changes compile/pass tests when possible"#; + +const RUN_SHELL_COMMAND_DOC: &str = r#"# run_shell_command + +Execute a shell command in the user's terminal. + +## Parameters +- `command` (string, required): The shell command to execute +- `is_read_only` (boolean, optional): Set true if command only reads data (ls, cat, grep, git status) +- `is_risky` (boolean, optional): Set true if command is destructive or irreversible + +## Behavior +- Runs in the user's actual shell (bash/zsh/fish) with their environment +- 2-second initial wait for output +- If command finishes: returns full output + exit code +- If still running after timeout: returns terminal snapshot (visible content) +- Long-running commands can be monitored via subsequent read_shell_command_output calls + +## Guidelines +- Always set is_read_only=true for read operations (this enables auto-execution) +- Set is_risky=true for: rm with -rf, git push --force, destructive database operations +- Combine related commands with && for efficiency +- Use | head -50 or | tail -20 for potentially large outputs +- Quote paths with spaces +- Prefer absolute paths + +## Examples +- Read-only: `{"command": "ls -la /path/to/dir", "is_read_only": true}` +- Risky: `{"command": "rm -rf ./build/", "is_risky": true}` +- Normal: `{"command": "cargo build 2>&1"}`"#; + +const READ_FILES_DOC: &str = r#"# read_files + +Read the contents of one or more files. + +## Parameters +- `files` (array of strings, required): Absolute file paths to read + +## Behavior +- Returns file contents with path headers +- 1MB cap per file +- Binary files are detected and skipped +- Images are resized and described +- Non-existent files return an error message + +## Guidelines +- Always read a file before editing it (to understand context) +- Use absolute paths (relative to the working directory shown in environment) +- Batch multiple files in one call for efficiency +- For large files, consider using grep first to find relevant sections + +## Examples +- Single file: `{"files": ["/home/user/project/src/main.rs"]}` +- Multiple: `{"files": ["/home/user/project/Cargo.toml", "/home/user/project/src/lib.rs"]}`"#; + +const APPLY_FILE_DIFFS_DOC: &str = r#"# apply_file_diffs + +Apply search/replace edits to files. Creates files if they don't exist (with empty search string). + +## Parameters +- `diffs` (array, required): Array of diff objects, each with: + - `file_path` (string): Absolute path to the file + - `search` (string): Exact text to find (must match uniquely) + - `replace` (string): Text to replace it with + +## Behavior +- Uses fuzzy matching to locate the search string in the file +- The search string must match exactly ONE location in the file +- If search is empty and file doesn't exist, creates the file with replace content +- Returns the updated file content and a unified diff +- User sees a diff view and can approve/reject + +## Guidelines +- Include enough context in search to ensure uniqueness (3-5 surrounding lines) +- Don't include line numbers in search/replace text +- For multiple edits in one file, apply them in one call with multiple diffs +- Preserve existing indentation style (tabs vs spaces) +- Read the file first to get the exact text to search for +- For new files, use search="" and put full content in replace + +## Examples +- Edit: `{"diffs": [{"file_path": "/path/file.rs", "search": "fn old_name()", "replace": "fn new_name()"}]}` +- Create: `{"diffs": [{"file_path": "/path/new.rs", "search": "", "replace": "fn main() {\n println!(\"hello\");\n}"}]}` +- Multi-edit: `{"diffs": [{"file_path": "/path/file.rs", "search": "use old;", "replace": "use new;"}, {"file_path": "/path/file.rs", "search": "old::call()", "replace": "new::call()"}]}`"#; + +const GREP_DOC: &str = r#"# grep + +Search for patterns in files using regex. + +## Parameters +- `queries` (array of strings, required): Regex patterns to search for +- `path` (string, optional): Directory to search in (defaults to working directory) + +## Behavior +- In git repos: uses git grep (respects .gitignore) +- Outside git: uses ripgrep +- 10-second timeout +- Returns file paths and matching line numbers (NOT content) +- Use read_files afterward to see the actual matching content + +## Guidelines +- Use simple patterns for speed (literal strings when possible) +- Scope searches with path parameter to avoid scanning huge directories +- Follow up with read_files to see context around matches +- Multiple queries are searched independently (OR logic) +- Regex syntax: standard ERE (extended regex) + +## Examples +- Simple: `{"queries": ["fn main"]}` +- Regex: `{"queries": ["impl.*Display"]}` +- Scoped: `{"queries": ["TODO", "FIXME"], "path": "/home/user/project/src"}`"#; + +const FILE_GLOB_DOC: &str = r#"# file_glob + +Find files matching glob patterns. + +## Parameters +- `patterns` (array of strings, required): Glob patterns to match + +## Behavior +- In git repos: uses git ls-files (respects .gitignore) +- Outside git: uses find +- 10-second timeout +- Returns absolute file paths of matching files +- Searches from working directory by default + +## Guidelines +- Use to discover project structure before making changes +- Common patterns: "**/*.rs", "src/**/*.ts", "**/Cargo.toml" +- Combine with read_files to inspect discovered files +- Use specific subdirectory patterns to narrow results + +## Examples +- All Rust files: `{"patterns": ["**/*.rs"]}` +- Config files: `{"patterns": ["**/Cargo.toml", "**/package.json"]}` +- Specific dir: `{"patterns": ["src/ai/**/*.rs"]}`"#; + +const GET_TOOL_DOCUMENTATION_DOC: &str = r#"# get_tool_documentation + +Get detailed usage documentation for any available tool. + +## Parameters +- `tool_name` (string, required): Name of the tool, or 'capabilities' for system overview + +## Available documentation +- `capabilities` — Full system overview, permissions, best practices +- `run_shell_command` — Shell execution details and guidelines +- `read_files` — File reading behavior and limits +- `apply_file_diffs` — File editing with search/replace +- `grep` — Pattern searching in files +- `file_glob` — File discovery with glob patterns +- `get_tool_documentation` — This documentation + +## When to use +Call this tool when you need detailed guidance on how to use a specific tool effectively, especially for complex operations like file editing or understanding the permission model."#; + +pub fn get_tool_documentation(tool_name: &str) -> Option { + match tool_name { + "capabilities" => Some(CAPABILITIES_DOC.to_string()), + "run_shell_command" => Some(RUN_SHELL_COMMAND_DOC.to_string()), + "read_files" => Some(READ_FILES_DOC.to_string()), + "apply_file_diffs" => Some(APPLY_FILE_DIFFS_DOC.to_string()), + "grep" => Some(GREP_DOC.to_string()), + "file_glob" => Some(FILE_GLOB_DOC.to_string()), + "get_tool_documentation" => Some(GET_TOOL_DOCUMENTATION_DOC.to_string()), + _ => None, + } +} diff --git a/app/src/auth/auth_manager.rs b/app/src/auth/auth_manager.rs index de358e08..42ee3d4b 100644 --- a/app/src/auth/auth_manager.rs +++ b/app/src/auth/auth_manager.rs @@ -552,12 +552,7 @@ impl AuthManager { /// NOTE: You probably want to call auth::log_out instead; this only manages the auth state, /// it doesn't shut down any other user-dependent parts of the app. /// TODO(jeff): Can we move those pieces in here? - pub(super) fn log_out(&mut self, ctx: &mut ModelContext) { - // Clear any dangling CSRF token from an auth flow that was started but never - // completed before this logout, so it can't be replayed against the next session - // in the same process. - self.pending_auth_state = None; - self.set_and_persist(None, None, ctx); + pub(super) fn log_out(&mut self, _ctx: &mut ModelContext) { } /// Sets whether or not this user's Firebase credentials are invalid and thus needs to reauth. @@ -572,20 +567,9 @@ impl AuthManager { pub fn create_anonymous_user( &self, - referral_code: Option, - ctx: &mut ModelContext, + _referral_code: Option, + _ctx: &mut ModelContext, ) { - let anonymous_user_type = AnonymousUserType::NativeClientAnonymousUserFeatureGated; - - let auth_client = self.auth_client.clone(); - let _ = ctx.spawn( - async move { - auth_client - .create_anonymous_user(referral_code, anonymous_user_type) - .await - }, - Self::on_create_anonymous_user, - ); } fn on_create_anonymous_user( @@ -634,26 +618,13 @@ impl AuthManager { pub fn attempt_login_gated_feature( &self, - feature: LoginGatedFeature, - auth_view_variant: AuthViewVariant, - ctx: &mut ModelContext, + _feature: LoginGatedFeature, + _auth_view_variant: AuthViewVariant, + _ctx: &mut ModelContext, ) { - if self.auth_state.is_anonymous_or_logged_out() { - send_telemetry_from_ctx!( - TelemetryEvent::AnonymousUserAttemptLoginGatedFeature { feature }, - ctx - ); - ctx.emit(AuthManagerEvent::AttemptedLoginGatedFeature { auth_view_variant }); - }; } - pub fn anonymous_user_hit_drive_object_limit(&self, ctx: &mut ModelContext) { - if self.auth_state.is_anonymous_or_logged_out() { - send_telemetry_from_ctx!(TelemetryEvent::AnonymousUserHitCloudObjectLimit, ctx); - ctx.emit(AuthManagerEvent::AttemptedLoginGatedFeature { - auth_view_variant: AuthViewVariant::HitDriveObjectLimitCloseable, - }); - }; + pub fn anonymous_user_hit_drive_object_limit(&self, _ctx: &mut ModelContext) { } pub fn initiate_anonymous_user_linking( diff --git a/app/src/auth/auth_override_warning_body.rs b/app/src/auth/auth_override_warning_body.rs deleted file mode 100644 index 35e54acc..00000000 --- a/app/src/auth/auth_override_warning_body.rs +++ /dev/null @@ -1,419 +0,0 @@ -use crate::appearance::Appearance; -use crate::util::color::lighten; -use galaxy_core::ui::builder::UiBuilder; -use galaxy_core::ui::color::darken; -use galaxyui::keymap::FixedBinding; - -use crate::modal::MODAL_CORNER_RADIUS; -use galaxy_core::ui::color::blend::Blend; -use galaxyui::accessibility::{AccessibilityContent, WarpA11yRole}; -use galaxyui::color::ColorU; -use galaxyui::elements::{ - ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex, Icon, - MouseStateHandle, ParentElement, Radius, Shrinkable, -}; -use galaxyui::fonts::Weight; -use galaxyui::ui_components::button::ButtonVariant; -use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; -use galaxyui::{ - AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, -}; - -const MODAL_PADDING: f32 = 32.; - -const AUTH_MODAL_GAP: f32 = 16.; -const BUTTON_ROW_GAP: f32 = 8.; -const ACTION_BUTTON_HEIGHT: f32 = 40.; -const ACTION_BUTTON_BORDER_WIDTH: f32 = 2.; -const ACTION_BUTTON_HORIZONTAL_PADDING: f32 = 8.; -const ACTION_BUTTON_FONT_SIZE: f32 = 14.; - -const AUTH_OVERRIDE_DESCRIPTION: &str = "It looks like you logged into a Warp account through a web browser. If you continue, any personal Warp drive objects and preferences from this anonymous session with be permanently deleted."; -const AUTH_OVERRIDE_CONFIRMATION_WARNING: &str = "This cannot be undone."; -const AUTH_OVERRIDE_INITIAL_STEP_HEADER: &str = "New login detected"; -const AUTH_OVERRIDE_CONFIRM_CONFIRMATION_STEP_HEADER: &str = - "Delete personal Warp Drive objects and preferences?"; -const AUTH_OVERRIDE_BULK_EXPORT_BUTTON_LABEL: &str = "Export your data"; -const AUTH_OVERRIDE_BULK_EXPORT_DESCRIPTION: &str = " to import later."; -const AUTH_OVERRIDE_CANCEL_BUTTON_LABEL: &str = "Cancel"; -const AUTH_OVERRIDE_CONTINUE_BUTTON_LABEL: &str = "Continue"; - -#[derive(Clone, Copy, Debug)] -pub enum AuthOverrideWarningBodyAction { - Close, - InitiateAllowLogin, - ConfirmAllowLogin, - BulkExport, -} - -enum AuthOverrideConfirmationStep { - Initial, - ConfirmChangeUser, -} - -#[derive(Default)] -struct MouseStateHandles { - cancel_button_mouse_state_handle: MouseStateHandle, - continue_button_mouse_state_handle: MouseStateHandle, - export_button_mouse_state_handle: MouseStateHandle, -} - -pub struct AuthOverrideWarningBody { - mouse_state_handles: MouseStateHandles, - confirmation_step: AuthOverrideConfirmationStep, -} - -pub fn init(app: &mut AppContext) { - use galaxyui::keymap::macros::*; - - app.register_fixed_bindings([FixedBinding::new( - "enter", - AuthOverrideWarningBodyAction::Close, - id!("AuthOverrideWarningBody"), - )]); - app.register_fixed_bindings([FixedBinding::new( - "escape", - AuthOverrideWarningBodyAction::Close, - id!("AuthOverrideWarningBody"), - )]); -} - -impl AuthOverrideWarningBody { - pub fn new() -> Self { - AuthOverrideWarningBody { - mouse_state_handles: Default::default(), - confirmation_step: AuthOverrideConfirmationStep::Initial, - } - } - - pub fn reset(&mut self) { - self.confirmation_step = AuthOverrideConfirmationStep::Initial; - } - - fn render_header(&self, appearance: &Appearance, ui_builder: &UiBuilder) -> Box { - let header_styles = UiComponentStyles { - font_family_id: Some(appearance.header_font_family()), - font_color: Some(appearance.theme().active_ui_text_color().into()), - font_size: Some(20.), - font_weight: Some(Weight::Semibold), - ..Default::default() - }; - - let text = match self.confirmation_step { - AuthOverrideConfirmationStep::Initial => AUTH_OVERRIDE_INITIAL_STEP_HEADER, - AuthOverrideConfirmationStep::ConfirmChangeUser => { - AUTH_OVERRIDE_CONFIRM_CONFIRMATION_STEP_HEADER - } - }; - - ui_builder - .span(text) - .with_soft_wrap() - .with_style(header_styles) - .build() - .finish() - } - - fn render_warning_icon(&self, appearance: &Appearance) -> Box { - let color = match self.confirmation_step { - AuthOverrideConfirmationStep::Initial => { - appearance.theme().terminal_colors().normal.yellow - } - AuthOverrideConfirmationStep::ConfirmChangeUser => { - appearance.theme().terminal_colors().normal.red - } - }; - ConstrainedBox::new( - Container::new(Icon::new("bundled/svg/alert-triangle.svg", color).finish()) - .with_background(appearance.theme().surface_1()) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.))) - .with_horizontal_padding(11.) - .finish(), - ) - .with_width(64.) - .with_height(64.) - .finish() - } - - fn render_warning_description( - &self, - appearance: &Appearance, - ui_builder: &UiBuilder, - ) -> Vec> { - let muted_styles = UiComponentStyles { - font_color: Some( - appearance - .theme() - .sub_text_color(appearance.theme().background()) - .into(), - ), - ..Default::default() - }; - - match self.confirmation_step { - AuthOverrideConfirmationStep::Initial => { - let description = Container::new( - ui_builder - .paragraph(AUTH_OVERRIDE_DESCRIPTION) - .with_style(muted_styles) - .build() - .finish(), - ) - .with_margin_top(AUTH_MODAL_GAP) - .finish(); - - let export = Container::new( - Flex::row() - .with_child( - ui_builder - .link( - AUTH_OVERRIDE_BULK_EXPORT_BUTTON_LABEL.into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action( - AuthOverrideWarningBodyAction::BulkExport, - ); - })), - self.mouse_state_handles - .export_button_mouse_state_handle - .clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .with_child( - ui_builder - .span(AUTH_OVERRIDE_BULK_EXPORT_DESCRIPTION) - .with_style(muted_styles) - .build() - .finish(), - ) - .finish(), - ) - .with_margin_top(AUTH_MODAL_GAP) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(); - - vec![description, export] - } - AuthOverrideConfirmationStep::ConfirmChangeUser => { - let confirmation = Container::new( - ui_builder - .paragraph(AUTH_OVERRIDE_CONFIRMATION_WARNING) - .with_style(muted_styles) - .build() - .finish(), - ) - .with_margin_top(AUTH_MODAL_GAP) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(); - - vec![confirmation] - } - } - } - - fn render_buttons(&self, appearance: &Appearance, ui_builder: &UiBuilder) -> Box { - let button_color = appearance.theme().accent().into(); - - let button_styles = UiComponentStyles { - font_size: Some(ACTION_BUTTON_FONT_SIZE), - font_family_id: Some(appearance.ui_font_family()), - font_weight: Some(Weight::Bold), - background: Some(Fill::Solid(button_color)), - border_width: Some(ACTION_BUTTON_BORDER_WIDTH), - border_color: Some(Fill::Solid(ColorU::transparent_black())), - border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), - padding: Some(Coords { - top: 0., - bottom: 0., - left: ACTION_BUTTON_HORIZONTAL_PADDING, - right: ACTION_BUTTON_HORIZONTAL_PADDING, - }), - height: Some(ACTION_BUTTON_HEIGHT), - ..Default::default() - }; - - let hover_button_style = UiComponentStyles { - border_color: Some(Fill::Solid(lighten(button_color))), - ..button_styles - }; - - let click_button_style = UiComponentStyles { - background: Some(Fill::Solid(darken(button_color))), - ..hover_button_style - }; - - let outline_color: ColorU = appearance.theme().accent().into(); - - let outline_button_styles = UiComponentStyles { - font_size: Some(ACTION_BUTTON_FONT_SIZE), - font_family_id: Some(appearance.ui_font_family()), - font_weight: Some(Weight::Bold), - border_width: Some(2.), - border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), - padding: Some(Coords { - top: 0., - bottom: 0., - left: ACTION_BUTTON_HORIZONTAL_PADDING, - right: ACTION_BUTTON_HORIZONTAL_PADDING, - }), - height: Some(ACTION_BUTTON_HEIGHT), - ..Default::default() - }; - - let outline_hover_button_style = UiComponentStyles { - border_color: Some(outline_color.into()), - font_color: Some(outline_color), - ..outline_button_styles - }; - - let outline_click_button_style = UiComponentStyles { - border_color: Some(Fill::Solid(darken(outline_color))), - ..outline_hover_button_style - }; - - let cancel_button = ui_builder - .button_with_custom_styles( - ButtonVariant::Accent, - self.mouse_state_handles - .cancel_button_mouse_state_handle - .clone(), - button_styles, - Some(hover_button_style), - Some(click_button_style), - None, - ) - .with_centered_text_label(AUTH_OVERRIDE_CANCEL_BUTTON_LABEL.into()) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(AuthOverrideWarningBodyAction::Close); - }) - .finish(); - - let continue_action = match self.confirmation_step { - AuthOverrideConfirmationStep::Initial => { - AuthOverrideWarningBodyAction::InitiateAllowLogin - } - AuthOverrideConfirmationStep::ConfirmChangeUser => { - AuthOverrideWarningBodyAction::ConfirmAllowLogin - } - }; - let continue_button = ui_builder - .button_with_custom_styles( - ButtonVariant::Outlined, - self.mouse_state_handles - .continue_button_mouse_state_handle - .clone(), - outline_button_styles, - Some(outline_hover_button_style), - Some(outline_click_button_style), - None, - ) - .with_centered_text_label(AUTH_OVERRIDE_CONTINUE_BUTTON_LABEL.into()) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(continue_action); - }) - .finish(); - - Flex::row() - .with_child( - Shrinkable::new( - 1., - Container::new(continue_button) - .with_margin_right(BUTTON_ROW_GAP) - .finish(), - ) - .finish(), - ) - .with_child(Shrinkable::new(1., cancel_button).finish()) - .finish() - } -} - -pub enum AuthOverrideWarningBodyEvent { - Close, - AllowLogin, - BulkExport, -} - -impl Entity for AuthOverrideWarningBody { - type Event = AuthOverrideWarningBodyEvent; -} - -impl TypedActionView for AuthOverrideWarningBody { - type Action = AuthOverrideWarningBodyAction; - - fn handle_action( - &mut self, - action: &AuthOverrideWarningBodyAction, - ctx: &mut ViewContext, - ) { - match action { - AuthOverrideWarningBodyAction::Close => { - ctx.emit(AuthOverrideWarningBodyEvent::Close); - } - AuthOverrideWarningBodyAction::InitiateAllowLogin => { - self.confirmation_step = AuthOverrideConfirmationStep::ConfirmChangeUser; - ctx.notify(); - } - AuthOverrideWarningBodyAction::ConfirmAllowLogin => { - ctx.emit(AuthOverrideWarningBodyEvent::AllowLogin); - } - AuthOverrideWarningBodyAction::BulkExport => { - ctx.emit(AuthOverrideWarningBodyEvent::BulkExport); - } - } - } -} - -impl View for AuthOverrideWarningBody { - fn ui_name() -> &'static str { - "AuthOverrideWarningBody" - } - - fn accessibility_contents(&self, _: &AppContext) -> Option { - Some(AccessibilityContent::new( - "New login detected", - "Warp has detected a new login from a web browser. Press escape to cancel and continue using Warp without login.", - WarpA11yRole::HelpRole, - )) - } - - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() { - ctx.focus_self(); - ctx.notify(); - } - } - - fn render(&self, app: &AppContext) -> Box { - let appearance = Appearance::as_ref(app); - let ui_builder = appearance.ui_builder(); - - let logo_row = Container::new(self.render_warning_icon(appearance)) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(); - - let content = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child(logo_row) - .with_child(self.render_header(appearance, ui_builder)) - .with_children(self.render_warning_description(appearance, ui_builder)) - .with_child(self.render_buttons(appearance, ui_builder)) - .finish(); - - Container::new(content) - .with_background( - appearance - .theme() - .background() - .blend(&appearance.theme().surface_1().with_opacity(50)), - ) - .with_corner_radius(CornerRadius::with_all(MODAL_CORNER_RADIUS)) - .with_uniform_padding(MODAL_PADDING) - .finish() - } -} diff --git a/app/src/auth/auth_override_warning_modal.rs b/app/src/auth/auth_override_warning_modal.rs index c45480d5..59787f19 100644 --- a/app/src/auth/auth_override_warning_modal.rs +++ b/app/src/auth/auth_override_warning_modal.rs @@ -1,114 +1,29 @@ -use pathfinder_color::ColorU; +use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext}; -use galaxy_core::ui::appearance::Appearance; -use galaxyui::elements::Container; -use galaxyui::elements::Fill; -use galaxyui::FocusContext; -use galaxyui::SingletonEntity; -use galaxyui::TypedActionView; - -use crate::auth::auth_override_warning_body::AuthOverrideWarningBody; -use crate::auth::auth_view_modal::AuthRedirectPayload; -use crate::modal::Modal; -use crate::root_view::unthemed_window_border; - -use galaxyui::elements::ChildView; -use galaxyui::ui_components::components::{Coords, UiComponentStyles}; -use galaxyui::{AppContext, Element, Entity, View, ViewContext, ViewHandle}; - -use super::auth_manager::AuthManager; -use super::auth_manager::AuthManagerEvent; -use super::auth_override_warning_body::AuthOverrideWarningBodyEvent; - -pub struct AuthOverrideWarningModal { - auth_override_warning_modal: ViewHandle>, - interrupted_auth_payload: Option, - variant: AuthOverrideWarningModalVariant, -} +use super::auth_view_modal::AuthRedirectPayload; +#[derive(Clone, Debug)] pub enum AuthOverrideWarningModalVariant { OnboardingView, WorkspaceModal, } -const MODAL_WIDTH: f32 = 364.; - -impl AuthOverrideWarningModal { - pub fn new(ctx: &mut ViewContext, variant: AuthOverrideWarningModalVariant) -> Self { - let auth_screen_view = ctx.add_typed_action_view(|_| AuthOverrideWarningBody::new()); - ctx.subscribe_to_view(&auth_screen_view, |me, _, event, ctx| match event { - AuthOverrideWarningBodyEvent::Close => me.close(ctx), - AuthOverrideWarningBodyEvent::AllowLogin => { - if let Some(auth_payload) = me.interrupted_auth_payload.clone() { - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.resume_interrupted_auth_payload(auth_payload, ctx); - }); - } - ctx.emit(AuthOverrideWarningModalEvent::Close); - } - AuthOverrideWarningBodyEvent::BulkExport => { - ctx.emit(AuthOverrideWarningModalEvent::BulkExport); - } - }); - - let auth_override_warning_modal = ctx.add_typed_action_view(|ctx| { - Modal::new(None, auth_screen_view, ctx) - .with_body_style(UiComponentStyles { - padding: Some(Coords::uniform(0.)), - ..Default::default() - }) - .with_modal_style(UiComponentStyles { - width: Some(MODAL_WIDTH), - border_color: Some(Fill::from(ColorU::transparent_black())), // override default modal border color - ..Default::default() - }) - }); - - let auth_manager = AuthManager::handle(ctx); - ctx.subscribe_to_model(&auth_manager, |me, _, event, ctx| { - me.handle_auth_manager_event(event, ctx); - }); - - Self { - auth_override_warning_modal, - interrupted_auth_payload: None, - variant, - } - } - - fn focus(&self, ctx: &mut ViewContext) { - ctx.focus(&self.auth_override_warning_modal); - ctx.notify(); - } - - fn close(&mut self, ctx: &mut ViewContext) { - ctx.emit(AuthOverrideWarningModalEvent::Close); - self.auth_override_warning_modal.update(ctx, |modal, ctx| { - modal.body().update(ctx, |body, _| { - body.reset(); - }) - }) - } - - pub fn set_interrupted_auth_payload(&mut self, auth_payload: AuthRedirectPayload) { - self.interrupted_auth_payload = Some(auth_payload); - } - - fn handle_auth_manager_event(&mut self, event: &AuthManagerEvent, ctx: &mut ViewContext) { - if let AuthManagerEvent::AuthComplete = event { - self.interrupted_auth_payload = None; - self.close(ctx); - } - ctx.notify(); - } -} - -#[derive(PartialEq, Eq)] +#[derive(Clone, Debug)] pub enum AuthOverrideWarningModalEvent { Close, BulkExport, } +pub struct AuthOverrideWarningModal; + +impl AuthOverrideWarningModal { + pub fn new(_ctx: &mut ViewContext, _variant: AuthOverrideWarningModalVariant) -> Self { + Self + } + + pub fn set_interrupted_auth_payload(&mut self, _payload: AuthRedirectPayload) {} +} + impl Entity for AuthOverrideWarningModal { type Event = AuthOverrideWarningModalEvent; } @@ -118,28 +33,12 @@ impl View for AuthOverrideWarningModal { "AuthOverrideWarningModal" } - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() { - self.focus(ctx); - } - } - - fn render(&self, ctx: &AppContext) -> Box { - let background_color = match self.variant { - AuthOverrideWarningModalVariant::OnboardingView => { - Appearance::as_ref(ctx).theme().background().into() - } - AuthOverrideWarningModalVariant::WorkspaceModal => ColorU::transparent_black(), - }; - - Container::new(ChildView::new(&self.auth_override_warning_modal).finish()) - .with_background_color(background_color) - .with_corner_radius(ctx.windows().window_corner_radius()) - .with_border(unthemed_window_border()) - .finish() + fn render(&self, _app: &AppContext) -> Box { + galaxyui::elements::Empty::new().finish() } } impl TypedActionView for AuthOverrideWarningModal { type Action = (); + fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext) {} } diff --git a/app/src/auth/auth_state.rs b/app/src/auth/auth_state.rs index c411a863..911b32e8 100644 --- a/app/src/auth/auth_state.rs +++ b/app/src/auth/auth_state.rs @@ -230,7 +230,7 @@ impl AuthState { /// Determines whether the user should be considered as logged in. pub fn is_logged_in(&self) -> bool { - self.credentials.read().is_some() + true } /// Returns whether the user should be treated as not having a full account. @@ -240,7 +240,7 @@ impl AuthState { /// during the transient state where credentials exist but user data hasn't loaded /// yet, the user is conservatively treated as lacking a full account. pub fn is_anonymous_or_logged_out(&self) -> bool { - !self.is_logged_in() || self.is_user_anonymous().unwrap_or(true) + false } /// Returns the cached access token, if any exists. This method *will not* check if the JWT is @@ -276,7 +276,7 @@ impl AuthState { /// Returns whether the user considered onboarded to Warp. pub fn is_onboarded(&self) -> Option { - self.user.read().as_ref().map(|user| user.is_onboarded) + Some(true) } /// Returns the user's email domain (anything after the @ sign of their email). @@ -296,59 +296,27 @@ impl AuthState { /// Anonymous users are real Warp users, but have no providers linked in Firebase. /// Returns `None` if there is no user data. pub fn is_user_anonymous(&self) -> Option { - self.user - .read() - .as_ref() - .map(|user| user.is_user_anonymous()) + Some(false) } /// Returns whether or not the user is a "web client anonymous user", aka their account /// originated from viewing Warp on web. pub fn is_user_web_anonymous_user(&self) -> Option { - self.user.read().as_ref().map(|user| { - user.anonymous_user_type() == Some(AnonymousUserType::WebClientAnonymousUser) - && user.linked_at().is_none() - }) + Some(false) } /// Returns whether or not the user is a feature gated anonymous user. pub fn is_anonymous_user_feature_gated(&self) -> Option { - self.user.read().as_ref().map(|user| { - if !self.is_user_anonymous().unwrap_or_default() { - return false; - } - - matches!( - user.anonymous_user_type(), - Some(AnonymousUserType::NativeClientAnonymousUserFeatureGated) - ) - }) + Some(false) } /// Returns whether or not the anonymous user is past any of their Warp Drive object limits. pub fn is_anonymous_user_past_object_limit( &self, - object_type: ObjectType, - num_objects: usize, + _object_type: ObjectType, + _num_objects: usize, ) -> Option { - self.user.read().as_ref().map(|user| { - if !self.is_anonymous_user_feature_gated().unwrap_or_default() { - return false; - } - - if let Some(limits) = user.personal_object_limits() { - match object_type { - ObjectType::Notebook => num_objects > limits.notebook_limit, - ObjectType::Workflow => num_objects > limits.workflow_limit, - ObjectType::GenericStringObject(GenericStringObjectFormat::Json( - JsonObjectType::EnvVarCollection, - )) => num_objects > limits.env_var_limit, - _ => false, - } - } else { - false - } - }) + Some(false) } /// Returns the user's photo URL from Firebase, @@ -363,7 +331,7 @@ impl AuthState { /// Returns whether or not the user needs to link their account to an SSO provider. /// The actual value is calculated on the server to avoid additional RPCs to Firebase. pub fn needs_sso_link(&self) -> Option { - self.user.read().as_ref().map(|user| user.needs_sso_link) + Some(false) } /// Returns the anonymous user type. @@ -406,7 +374,7 @@ impl AuthState { /// Returns whether a reauth is required for the current user given the state /// of their refresh token. pub fn needs_reauth(&self) -> bool { - self.needs_reauth.load(Ordering::Relaxed) + false } /// Sets whether a reauth is required for the current user. diff --git a/app/src/auth/auth_view_body.rs b/app/src/auth/auth_view_body.rs deleted file mode 100644 index 01b06edc..00000000 --- a/app/src/auth/auth_view_body.rs +++ /dev/null @@ -1,1093 +0,0 @@ -use crate::{ - appearance::Appearance, - auth::auth_view_shared_helpers::render_offline_contents, - editor::{EditorView, InteractionState, SingleLineEditorOptions, TextColors, TextOptions}, - experiments::{AuthFlowInstructions, Experiment}, - modal::MODAL_CORNER_RADIUS, - network::NetworkStatus, - report_error, send_telemetry_from_ctx, send_telemetry_sync_from_ctx, - server::telemetry::{AnonymousUserSignupEntrypoint, LoginEventSource, TelemetryEvent}, - settings::{AISettings, PrivacySettings}, - themes::theme::Fill as ThemeFill, - util::color::{darken, lighten}, -}; - -use anyhow::anyhow; -use lazy_static::lazy_static; -use galaxy_core::{ - features::FeatureFlag, - ui::{appearance::DEFAULT_COMMAND_PALETTE_FONT_SIZE, builder::UiBuilder}, -}; -use galaxyui::{ - accessibility::{AccessibilityContent, WarpA11yRole}, - clipboard::ClipboardContent, - color::ColorU, - elements::{ - Align, Border, Container, CornerRadius, CrossAxisAlignment, Dismiss, Fill, Flex, - MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Stack, - }, - fonts::Weight, - keymap::FixedBinding, - ui_components::components::{Coords, UiComponent, UiComponentStyles}, - AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, UpdateModel, View, - ViewContext, ViewHandle, -}; - -use super::{ - auth_manager::AuthManager, - auth_view_modal::AuthViewVariant, - auth_view_shared_helpers::{ - action_button_color_and_variant, render_offline_info_overlay_body, render_overlay, - render_privacy_settings_overlay_body, render_square_logo, PrivacySettingsActions, - PrivacySettingsHandles, - }, - AuthStateProvider, -}; - -const TOS_URL: &str = "https://www.warp.dev/terms-of-service"; - -const COMMON_BODY_UI_FONT_SIZE: f32 = 12.; -const AUTH_MODAL_GAP: f32 = 16.; - -const AUTH_TOKEN_INPUT_PLACEHOLDER_TEXT: &str = "Auth Token"; -const AUTH_TOKEN_INPUT_PLACEHOLDER_TEXT_EXPERIMENTAL: &str = "Browser auth token"; - -const AUTH_TOKEN_INPUT_BORDER_RADIUS: Radius = Radius::Pixels(4.); - -lazy_static! { - static ref BODY_TEXT_COLOR: ColorU = ColorU::new(157, 157, 157, 255); - static ref HOVERED_BODY_TEXT_COLOR: ColorU = lighten(*BODY_TEXT_COLOR); - static ref AUTH_TOKEN_INPUT_BACKGROUND: Fill = ColorU::white().into(); - static ref AUTH_TOKEN_INPUT_TEXT_COLOR: ThemeFill = ThemeFill::Solid(ColorU::black()); - static ref AUTH_TOKEN_INPUT_TEXT_DISABLED: ThemeFill = - AUTH_TOKEN_INPUT_TEXT_COLOR.with_opacity(20); - static ref AUTH_TOKEN_INPUT_TEXT_HINT: ThemeFill = AUTH_TOKEN_INPUT_TEXT_COLOR.with_opacity(40); -} - -pub fn init(app: &mut AppContext) { - use galaxyui::keymap::macros::*; - - app.register_fixed_bindings([FixedBinding::new( - "enter", - AuthViewBodyAction::Signup, - id!("AuthViewBody"), - )]); - app.register_fixed_bindings([FixedBinding::new( - "escape", - AuthViewBodyAction::Close, - id!("AuthViewBody"), - )]); -} - -#[derive(Default)] -struct MouseStateHandles { - login_link_mouse_state_handle: MouseStateHandle, - enter_login_later_mouse_state_handle: MouseStateHandle, - confirm_login_later_mouse_state_handle: MouseStateHandle, - show_auth_token_input_mouse_state_handle: MouseStateHandle, - copy_browser_url_mouse_state_handle: MouseStateHandle, - tos_mouse_state_handle: MouseStateHandle, - sign_up_mouse_state_handle: MouseStateHandle, - learn_more_mouse_state_handle: MouseStateHandle, - privacy_settings_mouse_state_handle: MouseStateHandle, - close_button_mouse_state_handle: MouseStateHandle, -} - -#[derive(Copy, Clone, Debug)] -pub enum AuthViewOverlay { - PrivacySettings, - OfflineInfo, -} - -pub struct AuthViewBody { - variant: AuthViewVariant, - mouse_state_handles: MouseStateHandles, - privacy_settings_handles: PrivacySettingsHandles, - active_overlay: Option, - auth_token_input: ViewHandle, - show_auth_token_input: bool, - auth_step: AuthStep, - loginless_step: LoginlessStep, - copy_url_click_count: u8, - allow_loginless: bool, -} - -/// State for two-step loginless flow for anonymous users -enum LoginlessStep { - /// Initial state: user has not yet clicked "sign up later" entrypoint - Start, - /// Confirmation state: user has clicked "sign up later" and is now in confirmation view - Initiated, -} - -pub enum AuthStep { - SelectAuthPathway, - BrowserOpen, -} - -#[derive(Clone, Copy, Debug)] -pub enum AuthViewBodyAction { - Login, - InitiateLoginLater, - LoginLater, - EnterToken, - CopyLoginUrl, - Signup, - SignupAnonymousUser, - ShowOverlay(AuthViewOverlay), - HideOverlay, - ToggleTelemetry, - ToggleCrashReporting, - ToggleCloudConversationStorage, - Close, -} - -impl AuthViewBody { - pub fn new(variant: AuthViewVariant, ctx: &mut ViewContext) -> Self { - let experiment_group = AuthFlowInstructions::get_group(ctx); - let auth_token_input = ctx.add_typed_action_view(|ctx| { - let appearance = Appearance::as_ref(ctx); - let mut editor = EditorView::single_line( - SingleLineEditorOptions { - text: TextOptions { - font_size_override: Some(COMMON_BODY_UI_FONT_SIZE), - font_family_override: Some(appearance.ui_font_family()), - text_colors_override: Some(TextColors { - default_color: *AUTH_TOKEN_INPUT_TEXT_COLOR, - disabled_color: *AUTH_TOKEN_INPUT_TEXT_DISABLED, - hint_color: *AUTH_TOKEN_INPUT_TEXT_HINT, - }), - ..Default::default() - }, - soft_wrap: false, - ..Default::default() - }, - ctx, - ); - - let placeholder_text = - if matches!(experiment_group, Some(AuthFlowInstructions::Experiment)) { - AUTH_TOKEN_INPUT_PLACEHOLDER_TEXT_EXPERIMENTAL - } else { - AUTH_TOKEN_INPUT_PLACEHOLDER_TEXT - }; - - editor.set_placeholder_text(placeholder_text, ctx); - editor - }); - - ctx.subscribe_to_view(&auth_token_input, |me, _, event, ctx| { - use crate::editor::Event::{AltEnter, CmdEnter, Enter, Paste, ShiftEnter}; - match event { - AltEnter | CmdEnter | Enter | Paste | ShiftEnter => me.emit_token_entered(ctx), - _ => {} - }; - ctx.notify(); - }); - - let allow_loginless = !FeatureFlag::ForceLogin.is_enabled(); - - let network_status = NetworkStatus::handle(ctx); - ctx.subscribe_to_model(&network_status, |_, _, _, ctx| { - ctx.notify(); - }); - - AuthViewBody { - variant, - mouse_state_handles: Default::default(), - privacy_settings_handles: Default::default(), - active_overlay: None, - auth_token_input, - show_auth_token_input: false, - auth_step: AuthStep::SelectAuthPathway, - loginless_step: LoginlessStep::Start, - copy_url_click_count: 0, - allow_loginless, - } - } - - pub fn handle_paste(&mut self, ctx: &mut ViewContext) { - self.show_auth_token_input = true; - self.auth_token_input - .update(ctx, |editor, ctx| editor.paste(ctx)); - } - - pub fn reset_login_screen(&mut self, ctx: &mut ViewContext) { - self.reset_auth_token_input(ctx); - self.auth_step = AuthStep::SelectAuthPathway; - self.loginless_step = LoginlessStep::Start; - self.copy_url_click_count = 0; - } - - fn reset_auth_token_input(&mut self, ctx: &mut ViewContext) { - self.set_input_editable(true, ctx); - self.auth_token_input - .update(ctx, |editor, ctx| editor.clear_buffer(ctx)); - self.show_auth_token_input = false; - } - - pub fn set_input_editable(&mut self, is_editable: bool, ctx: &mut ViewContext) { - let interaction_state = match is_editable { - false => InteractionState::Disabled, - true => InteractionState::Editable, - }; - self.auth_token_input.update(ctx, |editor, ctx| { - editor.set_interaction_state(interaction_state, ctx) - }); - } - - pub fn set_variant(&mut self, variant: AuthViewVariant) { - self.variant = variant; - } - - fn emit_token_entered(&self, ctx: &mut ViewContext) { - let text = self.auth_token_input.as_ref(ctx).buffer_text(ctx); - ctx.emit(AuthViewBodyEvent::AuthTokenEntered(text)); - } - - fn privacy_settings_actions(&self) -> PrivacySettingsActions { - PrivacySettingsActions { - toggle_telemetry: AuthViewBodyAction::ToggleTelemetry, - toggle_crash_reporting: AuthViewBodyAction::ToggleCrashReporting, - toggle_cloud_conversation_storage: AuthViewBodyAction::ToggleCloudConversationStorage, - hide_overlay: AuthViewBodyAction::HideOverlay, - } - } - - fn render_auth_token_suggest(&self, ui_builder: &UiBuilder) -> Box { - Flex::row() - .with_child( - ui_builder - .link( - "Click here to paste your token from the browser".into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action(AuthViewBodyAction::EnterToken); - })), - self.mouse_state_handles - .show_auth_token_input_mouse_state_handle - .clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .finish() - } - - fn render_auth_token_input(&self, appearance: &Appearance) -> Option> { - if !self.show_auth_token_input { - return None; - } - - Some( - appearance - .ui_builder() - .text_input(self.auth_token_input.clone()) - .with_style(UiComponentStyles { - background: Some(*AUTH_TOKEN_INPUT_BACKGROUND), - border_width: Some(0.), - border_radius: Some(CornerRadius::with_all(AUTH_TOKEN_INPUT_BORDER_RADIUS)), - padding: Some(Coords { - top: 12., - bottom: 12., - left: 16., - right: 16., - }), - margin: Some(Coords { - top: 8., - bottom: 0., - left: 0., - right: 0., - }), - ..Default::default() - }) - .build() - .finish(), - ) - } - - fn render_privacy_information( - &self, - appearance: &Appearance, - ui_builder: &UiBuilder, - ) -> Vec> { - let disclaimer_color = appearance - .theme() - .sub_text_color(appearance.theme().background()) - .into(); - - let disclaimer_styles = UiComponentStyles { - font_color: Some(disclaimer_color), - ..Default::default() - }; - - let link_styles = UiComponentStyles { - font_color: Some(disclaimer_color), - border_color: Some(Fill::Solid(disclaimer_color)), - ..Default::default() - }; - - let disclaimer_line_1 = Container::new( - Flex::row() - .with_child( - ui_builder - .span("By continuing, you agree to Warp's ") - .with_style(disclaimer_styles) - .build() - .finish(), - ) - .with_child( - ui_builder - .link( - "Terms of Service".into(), - Some(TOS_URL.into()), - None, - self.mouse_state_handles.tos_mouse_state_handle.clone(), - ) - .soft_wrap(false) - .with_style(link_styles) - .build() - .finish(), - ) - .finish(), - ) - .with_margin_top(AUTH_MODAL_GAP) - .with_margin_bottom(8.) - .finish(); - - let disclaimer_line_2 = if FeatureFlag::GlobalAIAnalyticsBanner.is_enabled() { - Align::new( - ui_builder - .link( - "Privacy Settings".into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action(AuthViewBodyAction::ShowOverlay( - AuthViewOverlay::PrivacySettings, - )); - })), - self.mouse_state_handles - .privacy_settings_mouse_state_handle - .clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .left() - .finish() - } else { - Flex::column() - .with_child( - ui_builder - .paragraph("If you'd like to opt out of analytics and AI features,") - .with_style(disclaimer_styles) - .build() - .finish(), - ) - .with_child( - Flex::row() - .with_child( - ui_builder - .paragraph("you can adjust your ") - .with_style(disclaimer_styles) - .build() - .finish(), - ) - .with_child( - ui_builder - .link( - "Privacy Settings".into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action(AuthViewBodyAction::ShowOverlay( - AuthViewOverlay::PrivacySettings, - )); - })), - self.mouse_state_handles - .privacy_settings_mouse_state_handle - .clone(), - ) - .soft_wrap(false) - .with_style(link_styles) - .build() - .finish(), - ) - .finish(), - ) - .finish() - }; - - vec![disclaimer_line_1, disclaimer_line_2] - } - - fn render_sign_up_button( - &self, - is_anonymous: bool, - appearance: &Appearance, - ui_builder: &UiBuilder, - ) -> Box { - let (button_color, button_variant) = action_button_color_and_variant(appearance); - let button_styles = UiComponentStyles { - font_size: Some(14.), - font_family_id: Some(appearance.ui_font_family()), - font_weight: Some(Weight::Bold), - background: Some(Fill::Solid(button_color)), - border_width: Some(2.), - border_color: Some(Fill::Solid(ColorU::transparent_black())), - border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), - padding: Some(Coords { - top: 0., - bottom: 0., - left: 12., // Unequal padding for optical centering - right: 8., - }), - height: Some(40.), - ..Default::default() - }; - - let hover_button_style = UiComponentStyles { - border_color: Some(Fill::Solid(lighten(button_color))), - ..button_styles - }; - - let click_button_style = UiComponentStyles { - background: Some(Fill::Solid(darken(button_color))), - ..hover_button_style - }; - - let on_click_action = if is_anonymous - && matches!( - self.variant, - AuthViewVariant::RequireLoginCloseable - | AuthViewVariant::HitDriveObjectLimitCloseable - | AuthViewVariant::ShareRequirementCloseable - ) { - AuthViewBodyAction::SignupAnonymousUser - } else { - AuthViewBodyAction::Signup - }; - - ui_builder - .button_with_custom_styles( - button_variant, - self.mouse_state_handles.sign_up_mouse_state_handle.clone(), - button_styles, - Some(hover_button_style), - Some(click_button_style), - None, - ) - .with_centered_text_label("Sign up".into()) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(on_click_action); - }) - .finish() - } - - fn render_sign_in_row(&self, ui_builder: &UiBuilder) -> Box { - Flex::row() - .with_child( - ui_builder - .span("Already have an account? ") - .build() - .finish(), - ) - .with_child( - ui_builder - .link( - "Sign in".into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action(AuthViewBodyAction::Login); - })), - self.mouse_state_handles - .login_link_mouse_state_handle - .clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .finish() - } - - fn render_sign_up_later_row(&self, ui_builder: &UiBuilder) -> Box { - Container::new( - Flex::row() - .with_child( - ui_builder - .span("Don't want to sign in right now? ") - .build() - .finish(), - ) - .with_child( - ui_builder - .link( - "Skip for now".into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action(AuthViewBodyAction::InitiateLoginLater); - })), - self.mouse_state_handles - .enter_login_later_mouse_state_handle - .clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .finish(), - ) - .with_margin_top(8.) - .finish() - } - - fn render_sign_in_later_confirm_row(&self, ui_builder: &UiBuilder) -> Box { - Container::new( - Flex::column() - .with_child( - ui_builder - .paragraph("Are you sure you want to skip login?") - .build() - .finish(), - ) - .with_child( - ui_builder - .paragraph("You can sign up later, but some features, such as AI,") - .build() - .finish(), - ) - .with_child( - Flex::row() - .with_child( - ui_builder - .span("are only available to logged-in users. ") - .build() - .finish(), - ) - .with_child( - ui_builder - .link( - "Yes, skip login".into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action(AuthViewBodyAction::LoginLater); - })), - self.mouse_state_handles - .confirm_login_later_mouse_state_handle - .clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .finish(), - ) - .finish(), - ) - .with_margin_top(8.) - .finish() - } - - fn render_force_login_disclaimer( - &self, - appearance: &Appearance, - ui_builder: &UiBuilder, - ) -> Box { - let disclaimer_color = appearance - .theme() - .sub_text_color(appearance.theme().background()) - .into(); - - let disclaimer_styles = UiComponentStyles { - font_color: Some(disclaimer_color), - ..Default::default() - }; - - let text = match self.variant { - AuthViewVariant::RequireLoginCloseable => { - "In order to use Warp’s AI features or collaborate with others, please create an account." - } - AuthViewVariant::HitDriveObjectLimitCloseable => { - "In order to create more objects in Warp Drive, please create an account." - } - AuthViewVariant::ShareRequirementCloseable => { - "In order to share, please create an account." - } - _ => "", - }; - - Container::new( - ui_builder - .paragraph(text) - .with_style(disclaimer_styles) - .build() - .finish(), - ) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish() - } - - fn render_header(&self, appearance: &Appearance, ui_builder: &UiBuilder) -> Box { - let header_styles = UiComponentStyles { - font_family_id: Some(appearance.header_font_family()), - font_color: Some(appearance.theme().active_ui_text_color().into()), - font_size: Some(20.), - font_weight: Some(Weight::Semibold), - ..Default::default() - }; - - let text = match self.variant { - AuthViewVariant::Initial => "Welcome to Warp!", - AuthViewVariant::RequireLoginCloseable - | AuthViewVariant::HitDriveObjectLimitCloseable - | AuthViewVariant::ShareRequirementCloseable => "Sign up for Warp", - }; - - ui_builder - .span(text) - .with_style(header_styles) - .build() - .finish() - } - - fn render_logo_row(&self, appearance: &Appearance, ui_builder: &UiBuilder) -> Box { - let logo = render_square_logo(appearance); - let mut row = Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) - .with_child(logo); - - if matches!( - self.variant, - AuthViewVariant::RequireLoginCloseable - | AuthViewVariant::HitDriveObjectLimitCloseable - | AuthViewVariant::ShareRequirementCloseable - ) { - let close_button = ui_builder - .close_button( - 24., - self.mouse_state_handles - .close_button_mouse_state_handle - .clone(), - ) - .build() - .on_click(|ctx, _, _| ctx.dispatch_typed_action(AuthViewBodyAction::Close)) - .finish(); - row = row.with_child(close_button) - }; - - row.finish() - } - - fn render_select_auth_pathway_content( - &self, - is_anonymous: bool, - appearance: &Appearance, - ui_builder: &UiBuilder, - app: &AppContext, - ) -> Vec> { - let logo = Container::new(self.render_logo_row(appearance, ui_builder)) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(); - let header = Container::new(self.render_header(appearance, ui_builder)) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(); - let sign_up_button = self.render_sign_up_button(is_anonymous, appearance, ui_builder); - let sign_in_row = Container::new(self.render_sign_in_row(ui_builder)) - .with_margin_top(AUTH_MODAL_GAP) - .finish(); - let force_login_disclaimer = self.render_force_login_disclaimer(appearance, ui_builder); - - match self.variant { - AuthViewVariant::Initial => { - if !NetworkStatus::as_ref(app).is_online() { - let offline_contents = render_offline_contents( - appearance, - ui_builder, - self.mouse_state_handles - .learn_more_mouse_state_handle - .clone(), - AuthViewBodyAction::ShowOverlay(AuthViewOverlay::OfflineInfo), - ); - vec![logo, header, offline_contents] - } else if self.active_overlay.is_none() { - let mut contents = if self.allow_loginless { - let sign_up_later_row = match self.loginless_step { - LoginlessStep::Start => self.render_sign_up_later_row(ui_builder), - LoginlessStep::Initiated => { - self.render_sign_in_later_confirm_row(ui_builder) - } - }; - vec![logo, header, sign_up_button, sign_in_row, sign_up_later_row] - } else { - vec![logo, header, sign_up_button, sign_in_row] - }; - - contents.append(&mut self.render_privacy_information(appearance, ui_builder)); - contents - } else { - vec![] - } - } - AuthViewVariant::RequireLoginCloseable - | AuthViewVariant::HitDriveObjectLimitCloseable - | AuthViewVariant::ShareRequirementCloseable => { - vec![logo, header, force_login_disclaimer, sign_up_button] - } - } - } - - fn render_browser_open_content( - &self, - appearance: &Appearance, - ui_builder: &UiBuilder, - ) -> Vec> { - let logo = Container::new(self.render_logo_row(appearance, ui_builder)) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(); - - let header_styles = UiComponentStyles { - font_family_id: Some(appearance.header_font_family()), - font_color: Some(appearance.theme().active_ui_text_color().into()), - font_size: Some(20.), - font_weight: Some(Weight::Semibold), - ..Default::default() - }; - - let header = Container::new( - ui_builder - .paragraph("Sign in on your browser \nto continue") - .with_style(header_styles) - .build() - .finish(), - ) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(); - - let hint = Container::new( - Flex::column() - .with_child( - Flex::row() - .with_child( - ui_builder - .span("If your browser hasn't launched, ") - .build() - .finish(), - ) - .with_child( - ui_builder - .link( - "copy the URL".into(), - None, - Some(Box::new(|event_ctx| { - event_ctx.dispatch_typed_action( - AuthViewBodyAction::CopyLoginUrl, - ); - })), - self.mouse_state_handles - .copy_browser_url_mouse_state_handle - .clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .finish(), - ) - .with_child( - ui_builder - .span("and open the page manually.") - .build() - .finish(), - ) - .finish(), - ) - .finish(); - - let mut contents = vec![logo, header, hint]; - - let auth_token = Container::new( - if let Some(auth_token_input) = self.render_auth_token_input(appearance) { - auth_token_input - } else { - self.render_auth_token_suggest(ui_builder) - }, - ) - .with_margin_top(AUTH_MODAL_GAP) - .finish(); - - contents.push(auth_token); - contents - } - - pub fn set_auth_step(&mut self, step: AuthStep) { - self.auth_step = step; - } -} - -pub enum AuthViewBodyEvent { - SignUpButtonClicked, - AuthTokenEntered(String), - LoginLaterClicked, - Close, -} - -impl Entity for AuthViewBody { - type Event = AuthViewBodyEvent; -} - -impl TypedActionView for AuthViewBody { - type Action = AuthViewBodyAction; - - fn handle_action(&mut self, action: &AuthViewBodyAction, ctx: &mut ViewContext) { - match action { - AuthViewBodyAction::Login => { - send_telemetry_from_ctx!( - TelemetryEvent::LoginButtonClicked { - source: LoginEventSource::AuthModal, - }, - ctx - ); - self.auth_step = AuthStep::BrowserOpen; - - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - let sign_in_url = auth_manager.sign_in_url(); - ctx.open_url(&sign_in_url); - }); - } - AuthViewBodyAction::InitiateLoginLater => { - send_telemetry_from_ctx!( - TelemetryEvent::LoginLaterButtonClicked { - source: LoginEventSource::AuthModal, - }, - ctx - ); - self.loginless_step = LoginlessStep::Initiated; - } - AuthViewBodyAction::LoginLater => { - // Send synchronously since this is an important event in the sign up funnel and we - // don't want to lose events if the user quits before the event queue is flushed. - send_telemetry_sync_from_ctx!( - TelemetryEvent::LoginLaterConfirmationButtonClicked { - source: LoginEventSource::AuthModal, - }, - ctx - ); - ctx.emit(AuthViewBodyEvent::LoginLaterClicked); - } - AuthViewBodyAction::EnterToken => { - self.auth_token_input - .update(ctx, |editor, ctx| editor.paste(ctx)); - self.show_auth_token_input = true; - - ctx.notify(); - } - AuthViewBodyAction::CopyLoginUrl => { - self.copy_url_click_count += 1; - if AuthStateProvider::as_ref(ctx) - .get() - .is_user_anonymous() - .unwrap_or_default() - { - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.copy_anonymous_user_linking_url_to_clipboard(ctx); - }); - } else { - AuthManager::handle(ctx).update(ctx, |auth_manager, inner_ctx| { - let sign_in_url = auth_manager.sign_in_url(); - inner_ctx.clipboard().write(ClipboardContent { - plain_text: sign_in_url.clone(), - paths: Some(vec![sign_in_url]), - ..Default::default() - }); - }); - } - } - AuthViewBodyAction::Signup => { - // Send synchronously since this is an important event in the sign up funnel and we - // don't want to lose events if the user quits before the event queue is flushed. - send_telemetry_sync_from_ctx!(TelemetryEvent::SignUpButtonClicked, ctx); - self.auth_step = AuthStep::BrowserOpen; - - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - let sign_up_url = auth_manager.sign_up_url(); - ctx.open_url(&sign_up_url); - }); - } - AuthViewBodyAction::SignupAnonymousUser => { - let entrypoint = match self.variant { - AuthViewVariant::RequireLoginCloseable - | AuthViewVariant::ShareRequirementCloseable => { - AnonymousUserSignupEntrypoint::LoginGatedFeature - } - AuthViewVariant::HitDriveObjectLimitCloseable => { - AnonymousUserSignupEntrypoint::HitDriveObjectLimit - } - AuthViewVariant::Initial => { - report_error!(anyhow!( - "Anonymous user initiated sign-up from unexpected AuthView variant" - )); - AnonymousUserSignupEntrypoint::Unknown - } - }; - - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.initiate_anonymous_user_linking(entrypoint, ctx); - }); - self.auth_step = AuthStep::BrowserOpen; - ctx.emit(AuthViewBodyEvent::SignUpButtonClicked); - } - AuthViewBodyAction::ShowOverlay(overlay) => { - if let AuthViewOverlay::PrivacySettings = overlay { - send_telemetry_sync_from_ctx!( - TelemetryEvent::OpenAuthPrivacySettings { - source: LoginEventSource::AuthModal, - }, - ctx - ); - } - self.active_overlay = Some(*overlay); - ctx.notify(); - } - AuthViewBodyAction::HideOverlay => { - self.active_overlay = None; - ctx.notify(); - } - AuthViewBodyAction::ToggleTelemetry => { - let privacy_settings_handle = PrivacySettings::handle(ctx); - ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| { - privacy_settings - .set_is_telemetry_enabled(!privacy_settings.is_telemetry_enabled, ctx); - }); - ctx.notify(); - } - AuthViewBodyAction::ToggleCrashReporting => { - let privacy_settings_handle = PrivacySettings::handle(ctx); - ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| { - privacy_settings.set_is_crash_reporting_enabled( - !privacy_settings.is_crash_reporting_enabled, - ctx, - ); - }); - ctx.notify(); - } - AuthViewBodyAction::ToggleCloudConversationStorage => { - let privacy_settings_handle = PrivacySettings::handle(ctx); - ctx.update_model(&privacy_settings_handle, |privacy_settings, ctx| { - privacy_settings.set_is_cloud_conversation_storage_enabled( - !privacy_settings.is_cloud_conversation_storage_enabled, - ctx, - ); - }); - ctx.notify(); - } - AuthViewBodyAction::Close => { - ctx.emit(AuthViewBodyEvent::Close); - } - } - } -} - -impl View for AuthViewBody { - fn ui_name() -> &'static str { - "AuthViewBody" - } - - fn accessibility_contents(&self, _: &AppContext) -> Option { - Some(AccessibilityContent::new( - "Welcome to Warp!", - "Press enter to open your browser to Sign Up or Sign In.", - WarpA11yRole::HelpRole, - )) - } - - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() { - ctx.notify(); - } - } - - fn render(&self, app: &AppContext) -> Box { - let appearance = Appearance::as_ref(app); - let ui_builder = UiBuilder::new( - appearance.theme().clone(), - appearance.ui_font_family(), - COMMON_BODY_UI_FONT_SIZE, - DEFAULT_COMMAND_PALETTE_FONT_SIZE, - appearance.line_height_ratio(), - ); - - let is_anonymous = AuthStateProvider::as_ref(app) - .get() - .is_user_anonymous() - .unwrap_or_default(); - - let mut content = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - content = content.with_children(match self.auth_step { - AuthStep::SelectAuthPathway => { - self.render_select_auth_pathway_content(is_anonymous, appearance, &ui_builder, app) - } - AuthStep::BrowserOpen => self.render_browser_open_content(appearance, &ui_builder), - }); - - let content = content.finish(); - - let mut stack = Stack::new(); - stack.add_child( - Container::new(content) - .with_background(appearance.theme().surface_1()) - .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) - .with_corner_radius(CornerRadius::with_all(MODAL_CORNER_RADIUS)) - .with_uniform_padding(32.) - .finish(), - ); - - if let Some(overlay) = &self.active_overlay { - match overlay { - AuthViewOverlay::PrivacySettings => { - // The `is_any_ai_enabled` helper also accounts for login / - // remote-session gating, so the cloud-conversation toggle - // hides whenever AI isn't effectively available. - let is_ai_enabled = AISettings::as_ref(app).is_any_ai_enabled(app); - stack.add_child( - Dismiss::new(render_overlay( - render_privacy_settings_overlay_body( - appearance, - app, - &self.privacy_settings_handles, - &self.privacy_settings_actions(), - is_ai_enabled, - ), - appearance, - )) - .on_dismiss(|ctx, _app| { - ctx.dispatch_typed_action(AuthViewBodyAction::HideOverlay) - }) - .finish(), - ); - } - AuthViewOverlay::OfflineInfo => { - stack.add_child( - Dismiss::new(render_overlay( - render_offline_info_overlay_body( - appearance, - self.privacy_settings_handles.close_button_mouse.clone(), - AuthViewBodyAction::HideOverlay, - ), - appearance, - )) - .on_dismiss(|ctx, _app| { - ctx.dispatch_typed_action(AuthViewBodyAction::HideOverlay) - }) - .finish(), - ); - } - } - } - - stack.finish() - } -} diff --git a/app/src/auth/auth_view_modal.rs b/app/src/auth/auth_view_modal.rs index 9c0b04ce..ba04f227 100644 --- a/app/src/auth/auth_view_modal.rs +++ b/app/src/auth/auth_view_modal.rs @@ -1,104 +1,11 @@ -use crate::appearance::Appearance; -use crate::root_view::unthemed_window_border; - -use crate::server::server_api::auth::UserAuthenticationError; -use crate::util::bindings::CustomAction; -use anyhow::{anyhow, Result}; -use pathfinder_color::ColorU; -use pathfinder_geometry::vector::vec2f; +use anyhow::Result; use url::Url; -use galaxy_core::errors::ErrorExt; -use galaxy_core::features::FeatureFlag; -use galaxyui::elements::ChildAnchor; -use galaxyui::elements::Container; -use galaxyui::elements::Fill; -use galaxyui::elements::HighlightedHyperlink; -use galaxyui::elements::MouseStateHandle; -use galaxyui::elements::OffsetPositioning; -use galaxyui::elements::ParentAnchor; -use galaxyui::elements::ParentElement; -use galaxyui::elements::ParentOffsetBounds; -use galaxyui::elements::Stack; -use galaxyui::keymap::FixedBinding; -use galaxyui::AppContext; -use galaxyui::FocusContext; -use galaxyui::SingletonEntity; -use galaxyui::TypedActionView; +use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext}; -use crate::auth::auth_view_body::AuthViewBody; -use crate::modal::Modal; -use std::collections::HashMap; -use galaxyui::elements::ChildView; -use galaxyui::ui_components::components::{Coords, UiComponentStyles}; -use galaxyui::{Element, Entity, View, ViewContext, ViewHandle}; - -use super::auth_manager::AuthManager; -use super::auth_manager::AuthManagerEvent; -use super::auth_view_body::AuthStep; -use super::auth_view_body::AuthViewBodyEvent; use super::credentials::RefreshToken; -use super::login_failure_notification::{self, LoginFailureReason}; -use super::UserUid; -use galaxyui::actions::StandardAction; - -pub fn init(app: &mut AppContext) { - use galaxyui::keymap::macros::*; - - app.register_fixed_bindings([ - // Bindings for paste require the StandardAction and CustomAction binding to work on all platforms. - FixedBinding::custom( - CustomAction::Paste, - AuthViewAction::PasteAuthUrl, - "Paste", - id!(AuthView::ui_name()), - ), - FixedBinding::standard( - StandardAction::Paste, - AuthViewAction::PasteAuthUrl, - id!(AuthView::ui_name()), - ), - ]); - - // For linux and Windows, default paste binding is ctrl+shift+v for PTY reasons. - // This can be confusing for users in some cases (and we might want - // to solve it in a more general way later). In the meantime, we - // add a basic ctrl+v binding for the auth view, since there is no - // terminal to interact with yet. - #[cfg(any(target_os = "linux", target_os = "windows"))] - app.register_fixed_bindings([FixedBinding::new( - "cmdorctrl-v", - AuthViewAction::PasteAuthUrl, - id!(AuthView::ui_name()), - )]); -} +use super::user_uid::UserUid; #[derive(Clone, Debug)] -pub enum AuthViewAction { - /// Triggered when the user attempts to paste something while the auth view - /// modal is visible. - PasteAuthUrl, - DismissErrorNotification, -} - -pub struct AuthView { - auth_screen_modal: ViewHandle>, - - // Reason for failing the most recent attempt to login, if any. When this is set, a - // notification containing the reason's error message is shown to the user. - pub last_login_failure_reason: Option, - close_login_notification_mouse_state: MouseStateHandle, - highlighted_hyperlink_state: HighlightedHyperlink, - auth_view_variant: AuthViewVariant, -} - -const AUTH_URL_HOST: &str = "auth"; -const AUTH_URL_REFRESH_TOKEN_QUERY_PARAM: &str = "refresh_token"; -const AUTH_URL_NEW_USER_UID_QUERY_PARAM: &str = "user_uid"; -const AUTH_URL_DELETED_ANON_USER_QUERY_PARAM: &str = "deleted_anonymous_user"; -const AUTH_URL_STATE_QUERY_PARAM: &str = "state"; - -// `AuthRedirectPayload` is returned from the incoming redirect url. -#[derive(Debug, Clone)] pub struct AuthRedirectPayload { pub refresh_token: RefreshToken, pub user_uid: Option, @@ -107,226 +14,39 @@ pub struct AuthRedirectPayload { } impl AuthRedirectPayload { - /// Attempts to parse the `AuthRedirectPayload` from URL sent to Warp. To parse successfully, the URL - /// must be of format {scheme}://auth/desktop_redirect?refresh_token={token}. - pub fn from_url(url: Url) -> Result { - if url.host_str() != Some(AUTH_URL_HOST) { - return Err(anyhow!("Received URL with unexpected host: {} ", url)); - } - let query_params: HashMap<_, _> = url.query_pairs().into_owned().collect(); - if let Some(token) = query_params.get(AUTH_URL_REFRESH_TOKEN_QUERY_PARAM) { - let user_uid = query_params - .get(AUTH_URL_NEW_USER_UID_QUERY_PARAM) - .map(|uid| UserUid::new(uid)); - - Ok(Self { - refresh_token: RefreshToken::new(token), - user_uid, - deleted_anonymous_user: query_params - .get(AUTH_URL_DELETED_ANON_USER_QUERY_PARAM) - .map(|value| value == "true"), - state: query_params.get(AUTH_URL_STATE_QUERY_PARAM).cloned(), - }) - } else { - Err(anyhow!( - "Received URL without refresh token query param: {}", - url - )) - } - } - - /// Like [`from_url()`], except first parses the given [`raw_url`] into a [`Url`] struct. - pub fn from_raw_url(raw_url: String) -> Result { - match Url::parse(&raw_url) { - Ok(parsed_url) => AuthRedirectPayload::from_url(parsed_url), - Err(error) => Err(anyhow!(error)), - } + pub fn from_url(_url: Url) -> Result { + anyhow::bail!("Auth UI removed") } } -const MODAL_WIDTH: f32 = 352.; - #[derive(Clone, Copy, Debug)] pub enum AuthViewVariant { Initial, RequireLoginCloseable, - HitDriveObjectLimitCloseable, ShareRequirementCloseable, } -impl AuthView { - pub fn new(variant: AuthViewVariant, ctx: &mut ViewContext) -> Self { - let auth_screen_view = ctx.add_typed_action_view(|ctx| AuthViewBody::new(variant, ctx)); - ctx.subscribe_to_view(&auth_screen_view, |me, _, event, ctx| match event { - AuthViewBodyEvent::Close => me.close(ctx), - AuthViewBodyEvent::SignUpButtonClicked => { - me.dismiss_error_notification(ctx); - } - AuthViewBodyEvent::AuthTokenEntered(token) => { - me.last_login_failure_reason = None; - me.handle_pasted_auth_url(token.clone(), ctx); - ctx.notify(); - } - AuthViewBodyEvent::LoginLaterClicked => { - me.handle_login_later(ctx); - } - }); - - let auth_screen_modal = ctx.add_typed_action_view(|ctx| { - Modal::new(None, auth_screen_view, ctx) - .with_body_style(UiComponentStyles { - padding: Some(Coords::uniform(0.)), - ..Default::default() - }) - .with_modal_style(UiComponentStyles { - width: Some(MODAL_WIDTH), - border_color: Some(Fill::from(ColorU::transparent_black())), // override default modal border color - ..Default::default() - }) - }); - - let auth_manager = AuthManager::handle(ctx); - ctx.subscribe_to_model(&auth_manager, |me, _, event, ctx| { - me.handle_auth_manager_event(event, ctx); - }); - - Self { - auth_screen_modal, - last_login_failure_reason: None, - close_login_notification_mouse_state: Default::default(), - highlighted_hyperlink_state: Default::default(), - auth_view_variant: variant, - } - } - - pub fn set_variant(&mut self, ctx: &mut ViewContext, variant: AuthViewVariant) { - self.auth_view_variant = variant; - self.update_auth_body( - ctx, - |body: &mut AuthViewBody, _: &mut ViewContext<'_, AuthViewBody>| { - body.set_variant(variant) - }, - ); - } - - fn set_auth_step(&mut self, ctx: &mut ViewContext, step: AuthStep) { - self.update_auth_body( - ctx, - |body: &mut AuthViewBody, _: &mut ViewContext<'_, AuthViewBody>| { - body.set_auth_step(step) - }, - ); - } - - pub fn skip_to_browser_open_step(&mut self, ctx: &mut ViewContext) { - self.set_auth_step(ctx, AuthStep::BrowserOpen); - } - - fn focus(&self, ctx: &mut ViewContext) { - ctx.focus(&self.auth_screen_modal); - ctx.notify(); - } - - fn dismiss_error_notification(&mut self, ctx: &mut ViewContext) { - self.last_login_failure_reason = None; - ctx.notify(); - } - - fn close(&mut self, ctx: &mut ViewContext) { - self.update_auth_body( - ctx, - |body: &mut AuthViewBody, ctx: &mut ViewContext<'_, AuthViewBody>| { - body.reset_login_screen(ctx) - }, - ); - self.dismiss_error_notification(ctx); - ctx.emit(AuthViewEvent::Close); - } - - /// Parses the given 'clipboard_content' string into a URL which is assumed to represent the - /// OAuth redirect URL containing the user's refresh token after the user authenticated Warp. - fn handle_pasted_auth_url(&mut self, pasted_url: String, ctx: &mut ViewContext) { - self.set_auth_token_input_editable(false, ctx); - match AuthRedirectPayload::from_raw_url(pasted_url) { - Ok(redirect_payload) => { - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.initialize_user_from_auth_payload(redirect_payload, true, ctx); - }); - } - Err(error) => { - log::error!("Failed to parse AuthRedirectPayload from redirect URL: {error:#}"); - self.last_login_failure_reason = - Some(LoginFailureReason::InvalidRedirectUrl { was_pasted: true }); - self.set_auth_token_input_editable(true, ctx); - } - } - } - - fn set_auth_token_input_editable(&mut self, is_editable: bool, ctx: &mut ViewContext) { - self.update_auth_body(ctx, |body, ctx| body.set_input_editable(is_editable, ctx)) - } - - fn update_auth_body(&mut self, ctx: &mut ViewContext, cb: F) -> S - where - F: FnOnce(&mut AuthViewBody, &mut ViewContext<'_, AuthViewBody>) -> S, - { - self.auth_screen_modal - .update(ctx, |modal, ctx| modal.body().update(ctx, cb)) - } - - pub fn handle_login_later(&mut self, ctx: &mut ViewContext) { - if FeatureFlag::SkipFirebaseAnonymousUser.is_enabled() { - AuthManager::handle(ctx).update(ctx, |_, ctx| { - ctx.emit(AuthManagerEvent::SkippedLogin); - }); - } else { - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.create_anonymous_user(None, ctx) - }); - } - } - - fn handle_auth_manager_event(&mut self, event: &AuthManagerEvent, ctx: &mut ViewContext) { - match event { - AuthManagerEvent::AuthComplete | AuthManagerEvent::SkippedLogin => { - self.close(ctx); - } - AuthManagerEvent::AuthFailed(err) => { - if err.is_actionable() { - log::error!("Failed to log in user: {err:#}"); - } - - if let UserAuthenticationError::InvalidStateParameter = err { - self.last_login_failure_reason = - Some(LoginFailureReason::InvalidStateParameter); - } else if let UserAuthenticationError::MissingStateParameter = err { - self.last_login_failure_reason = - Some(LoginFailureReason::MissingStateParameter); - } else { - self.last_login_failure_reason = - Some(LoginFailureReason::FailedUserAuthentication); - } - - self.set_auth_token_input_editable(true, ctx); - } - AuthManagerEvent::CreateAnonymousUserFailed => { - self.last_login_failure_reason = Some(LoginFailureReason::FailedUserAuthentication); - self.set_auth_token_input_editable(true, ctx); - } - AuthManagerEvent::MintCustomTokenFailed(_err) => { - self.last_login_failure_reason = Some(LoginFailureReason::FailedMintCustomToken); - } - _ => {} - } - ctx.notify(); - } -} - -#[derive(PartialEq, Eq)] +#[derive(Clone, Debug)] pub enum AuthViewEvent { Close, } +pub struct AuthView { + pub last_login_failure_reason: Option, +} + +impl AuthView { + pub fn new(_variant: AuthViewVariant, _ctx: &mut ViewContext) -> Self { + Self { + last_login_failure_reason: None, + } + } + + pub fn set_variant(&mut self, _ctx: &mut ViewContext, _variant: AuthViewVariant) {} + + pub fn skip_to_browser_open_step(&mut self, _ctx: &mut ViewContext) {} +} + impl Entity for AuthView { type Event = AuthViewEvent; } @@ -336,66 +56,19 @@ impl View for AuthView { "AuthView" } - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() { - self.focus(ctx); - } - } - - fn render(&self, ctx: &AppContext) -> Box { - let appearance = Appearance::as_ref(ctx); - let mut stack = Stack::new(); - stack.add_child(ChildView::new(&self.auth_screen_modal).finish()); - - if let Some(login_failure_reason) = &self.last_login_failure_reason { - let login_failure_notification = login_failure_notification::render( - login_failure_reason, - self.close_login_notification_mouse_state.clone(), - self.highlighted_hyperlink_state.clone(), - AuthViewAction::DismissErrorNotification, - ctx, - ); - stack.add_positioned_overlay_child( - login_failure_notification, - OffsetPositioning::offset_from_parent( - vec2f(0., 40.), - ParentOffsetBounds::ParentBySize, - ParentAnchor::TopMiddle, - ChildAnchor::TopMiddle, - ), - ); - } - - let background_color = match self.auth_view_variant { - AuthViewVariant::Initial => appearance.theme().background().into(), - AuthViewVariant::RequireLoginCloseable - | AuthViewVariant::HitDriveObjectLimitCloseable - | AuthViewVariant::ShareRequirementCloseable => ColorU::transparent_black(), - }; - - // TODO(liam): use theme colors for background and window border - Container::new(stack.finish()) - .with_background_color(background_color) - .with_corner_radius(ctx.windows().window_corner_radius()) - .with_border(unthemed_window_border()) - .finish() + fn render(&self, _app: &AppContext) -> Box { + galaxyui::elements::Empty::new().finish() } } impl TypedActionView for AuthView { - type Action = AuthViewAction; - - fn handle_action(&mut self, action: &AuthViewAction, ctx: &mut ViewContext) { - match action { - AuthViewAction::PasteAuthUrl => { - self.last_login_failure_reason = None; - self.update_auth_body(ctx, |body, ctx| body.handle_paste(ctx)); - - ctx.notify(); - } - AuthViewAction::DismissErrorNotification => { - self.dismiss_error_notification(ctx); - } - } - } + type Action = (); + fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext) {} } + +#[derive(Clone, Debug)] +pub enum LoginFailureReason { + InvalidRedirectUrl { was_pasted: bool }, +} + +pub fn init(_app: &mut AppContext) {} diff --git a/app/src/auth/auth_view_shared_helpers.rs b/app/src/auth/auth_view_shared_helpers.rs deleted file mode 100644 index bf648d96..00000000 --- a/app/src/auth/auth_view_shared_helpers.rs +++ /dev/null @@ -1,609 +0,0 @@ -use pathfinder_color::ColorU; -use galaxy_core::channel::ChannelState; -use galaxy_core::features::FeatureFlag; -use galaxy_core::ui::{ - appearance::Appearance, - builder::UiBuilder, - color::{darken, lighten}, - theme::ColorScheme, -}; -use galaxyui::{ - assets::asset_cache::AssetSource, - elements::{ - Border, CacheOption, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, - Flex, Image, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, - Shrinkable, - }, - fonts::Weight, - ui_components::{ - button::ButtonVariant, - components::{Coords, UiComponent, UiComponentStyles}, - switch::SwitchStateHandle, - }, - Action, AppContext, Element, SingletonEntity as _, -}; - -use crate::settings::PrivacySettings; -use crate::themes::theme::ThemeKind; - -const PRIVACY_URL: &str = "https://warp.dev/privacy"; - -pub const AUTH_MODAL_GAP: f32 = 16.; -const MODAL_CORNER_RADIUS: Radius = Radius::Pixels(8.); - -pub fn action_button_color_and_variant(appearance: &Appearance) -> (ColorU, ButtonVariant) { - let (button_color, button_variant) = match appearance.theme().name() { - Some(name) if ThemeKind::Dark.matches(&name) => { - (ColorU::new(0, 109, 168, 255), ButtonVariant::Basic) - } - Some(_) => (appearance.theme().accent().into(), ButtonVariant::Accent), - None => (appearance.theme().accent().into(), ButtonVariant::Accent), - }; - (button_color, button_variant) -} - -pub fn render_offline_contents( - appearance: &Appearance, - ui_builder: &UiBuilder, - mouse_state_handle: MouseStateHandle, - action: A, -) -> Box -where - A: Action + Clone, -{ - let disclaimer_color = appearance - .theme() - .sub_text_color(appearance.theme().background()) - .into(); - - let disclaimer_styles = UiComponentStyles { - font_color: Some(disclaimer_color), - ..Default::default() - }; - - let text = "You are currently offline. An internet connection is required to use Warp for the first time."; - - let (button_color, button_variant) = action_button_color_and_variant(appearance); - let button_styles = UiComponentStyles { - font_size: Some(14.), - font_family_id: Some(appearance.ui_font_family()), - font_weight: Some(Weight::Bold), - background: Some(Fill::Solid(button_color)), - border_width: Some(2.), - border_color: Some(Fill::Solid(ColorU::transparent_black())), - border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), - padding: Some(Coords { - top: 0., - bottom: 0., - left: 8., - right: 8., - }), - height: Some(40.), - ..Default::default() - }; - - let hover_button_style = UiComponentStyles { - border_color: Some(Fill::Solid(lighten(button_color))), - ..button_styles - }; - - let click_button_style = UiComponentStyles { - background: Some(Fill::Solid(darken(button_color))), - ..hover_button_style - }; - - let button = ui_builder - .button_with_custom_styles( - button_variant, - mouse_state_handle.clone(), - button_styles, - Some(hover_button_style), - Some(click_button_style), - None, - ) - .with_centered_text_label("Learn more".into()) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(action.clone()); - }) - .finish(); - - Flex::column() - .with_child( - Container::new( - ui_builder - .paragraph(text) - .with_style(disclaimer_styles) - .build() - .finish(), - ) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - ) - .with_child(button) - .finish() -} - -pub fn render_square_logo(appearance: &Appearance) -> Box { - let image_path = if appearance.theme().inferred_color_scheme() == ColorScheme::LightOnDark { - "bundled/svg/warp-logo-light.svg" - } else { - "bundled/svg/warp-logo-dark.svg" - }; - - ConstrainedBox::new( - Container::new( - Image::new( - AssetSource::Bundled { path: image_path }, - CacheOption::BySize, - ) - .finish(), - ) - .with_background(appearance.theme().surface_2()) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(10.))) - .with_horizontal_padding(11.) - .finish(), - ) - .with_width(64.) - .with_height(64.) - .finish() -} - -pub fn render_offline_info_overlay_body( - appearance: &Appearance, - mouse_state_handle: MouseStateHandle, - action: A, -) -> Box -where - A: Action + Clone, -{ - let header_styles = UiComponentStyles { - font_family_id: Some(appearance.header_font_family()), - font_color: Some(appearance.theme().active_ui_text_color().into()), - font_size: Some(20.), - font_weight: Some(Weight::Semibold), - ..Default::default() - }; - - let body_text_color = appearance - .theme() - .sub_text_color(appearance.theme().background()) - .into(); - - let body_text_styles = UiComponentStyles { - font_color: Some(body_text_color), - ..Default::default() - }; - - let paragraph_1 = "All of Warp’s non-cloud features work offline."; - let paragraph_2 = "However, we require users to be online when using Warp for the first time in order to enable Warp's AI and cloud features."; - let paragraph_3 = "We offer cloud features to all users, and so we need an internet connection to meter AI usage, prevent abuse, and associate cloud objects with users. If you opt to use Warp logged-out, a unique ID will be attached to an anonymous user account in order to support these features."; - - Container::new( - Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child( - Container::new(render_square_logo(appearance)) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - ) - .with_child( - Container::new( - appearance - .ui_builder() - .span("Using Warp Offline") - .with_style(header_styles) - .build() - .finish(), - ) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - ) - .with_child( - Container::new( - appearance - .ui_builder() - .paragraph(paragraph_1) - .with_style(body_text_styles) - .build() - .finish(), - ) - .with_margin_bottom(4.) - .finish(), - ) - .with_child( - Container::new( - appearance - .ui_builder() - .paragraph(paragraph_2) - .with_style(body_text_styles) - .build() - .finish(), - ) - .with_margin_bottom(4.) - .finish(), - ) - .with_child( - Container::new( - appearance - .ui_builder() - .paragraph(paragraph_3) - .with_style(body_text_styles) - .build() - .finish(), - ) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - ) - .with_child(render_close_overlay_button( - appearance, - appearance.ui_builder(), - "Dismiss".into(), - mouse_state_handle, - action, - )) - .finish(), - ) - .finish() -} - -pub fn render_close_overlay_button( - appearance: &Appearance, - ui_builder: &UiBuilder, - label: String, - mouse_state_handle: MouseStateHandle, - action: A, -) -> Box -where - A: Action + Clone, -{ - let (button_color, button_variant) = action_button_color_and_variant(appearance); - let button_styles = UiComponentStyles { - font_size: Some(14.), - font_family_id: Some(appearance.ui_font_family()), - font_weight: Some(Weight::Bold), - background: Some(Fill::Solid(button_color)), - border_width: Some(2.), - border_color: Some(Fill::Solid(ColorU::transparent_black())), - border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), - padding: Some(Coords { - top: 0., - bottom: 0., - left: 8., - right: 8., - }), - height: Some(40.), - ..Default::default() - }; - - let hover_button_style = UiComponentStyles { - border_color: Some(Fill::Solid(lighten(button_color))), - ..button_styles - }; - - let click_button_style = UiComponentStyles { - background: Some(Fill::Solid(darken(button_color))), - ..hover_button_style - }; - - ui_builder - .button_with_custom_styles( - button_variant, - mouse_state_handle.clone(), - button_styles, - Some(hover_button_style), - Some(click_button_style), - None, - ) - .with_centered_text_label(label) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(action.clone()); - }) - .finish() -} - -pub fn render_overlay(overlay_body: Box, appearance: &Appearance) -> Box { - Container::new(overlay_body) - .with_background(appearance.theme().surface_1()) - .with_border(Border::all(1.).with_border_fill(appearance.theme().outline())) - .with_corner_radius(CornerRadius::with_all(MODAL_CORNER_RADIUS)) - .with_uniform_padding(32.) - .finish() -} - -// --------------------------------------------------------------------------- -// Privacy settings overlay (shared between AuthViewBody and LoginSlideView) -// --------------------------------------------------------------------------- - -/// Handles needed to render the privacy settings overlay. -#[derive(Default)] -pub struct PrivacySettingsHandles { - pub telemetry_switch: SwitchStateHandle, - pub crash_reporting_switch: SwitchStateHandle, - pub cloud_conversation_storage_switch: SwitchStateHandle, - pub close_button_mouse: MouseStateHandle, - pub telemetry_docs_mouse: MouseStateHandle, -} - -/// Actions dispatched by the privacy settings overlay toggles. -pub struct PrivacySettingsActions { - pub toggle_telemetry: A, - pub toggle_crash_reporting: A, - pub toggle_cloud_conversation_storage: A, - pub hide_overlay: A, -} - -/// Renders the full privacy settings overlay body (logo + header + toggles + done button). -/// This is the content that goes inside `render_overlay()`. -/// -/// `is_ai_enabled` gates whether AI-dependent toggles (e.g. the cloud conversation -/// storage toggle) are shown. Callers should pass the effective AI-enabled state -/// for their context (the in-memory onboarding selection during the login slide, -/// or the stored setting elsewhere). -pub fn render_privacy_settings_overlay_body( - appearance: &Appearance, - app: &AppContext, - handles: &PrivacySettingsHandles, - actions: &PrivacySettingsActions, - is_ai_enabled: bool, -) -> Box { - let ui_builder = appearance.ui_builder(); - - let header_styles = UiComponentStyles { - font_family_id: Some(appearance.header_font_family()), - font_color: Some(appearance.theme().active_ui_text_color().into()), - font_size: Some(20.), - font_weight: Some(Weight::Semibold), - ..Default::default() - }; - - Container::new( - Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child( - Container::new(render_square_logo(appearance)) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - ) - .with_child( - Container::new( - ui_builder - .span("Privacy Settings") - .with_style(header_styles) - .build() - .finish(), - ) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - ) - .with_child(render_privacy_settings_toggles( - appearance, - app, - handles, - actions, - is_ai_enabled, - )) - .with_child(render_close_overlay_button( - appearance, - ui_builder, - "Done".into(), - handles.close_button_mouse.clone(), - actions.hide_overlay.clone(), - )) - .finish(), - ) - .with_background(appearance.theme().surface_1()) - .finish() -} - -fn render_privacy_settings_section_header( - text: impl Into, - appearance: &Appearance, -) -> Container { - let section_header_styles = UiComponentStyles { - font_family_id: Some(appearance.header_font_family()), - font_color: Some(appearance.theme().active_ui_text_color().into()), - font_size: Some(14.), - font_weight: Some(Weight::Bold), - ..Default::default() - }; - - Container::new( - appearance - .ui_builder() - .span(text.into()) - .with_style(section_header_styles) - .build() - .finish(), - ) -} - -/// Renders the stack of privacy toggles shown in the privacy settings overlay. -/// -/// `is_ai_enabled` gates AI-dependent toggles (the cloud conversation storage -/// toggle is hidden entirely when AI is disabled, since it has no effect). -pub fn render_privacy_settings_toggles( - appearance: &Appearance, - app: &AppContext, - handles: &PrivacySettingsHandles, - actions: &PrivacySettingsActions, - is_ai_enabled: bool, -) -> Box { - fn render_description(appearance: &Appearance, text: String) -> Box { - let disclaimer_styles = UiComponentStyles { - font_color: Some( - appearance - .theme() - .sub_text_color(appearance.theme().background()) - .into(), - ), - ..Default::default() - }; - - appearance - .ui_builder() - .paragraph(text) - .with_style(disclaimer_styles) - .build() - .finish() - } - - let toggle_telemetry = actions.toggle_telemetry.clone(); - let telemetry_toggle = Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) - .with_child( - Shrinkable::new( - 1., - render_privacy_settings_section_header("Help improve Warp", appearance).finish(), - ) - .finish(), - ) - .with_child( - appearance - .ui_builder() - .switch(handles.telemetry_switch.clone()) - .check(PrivacySettings::as_ref(app).is_telemetry_enabled) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(toggle_telemetry.clone()); - }) - .finish(), - ) - .finish(); - - let telemetry_description = render_description( - appearance, - "High-level feature usage data helps Warp's product team prioritize the roadmap.".into(), - ); - - let telemetry_link = Flex::row() - .with_child( - appearance - .ui_builder() - .link( - "Learn more".into(), - Some(PRIVACY_URL.into()), - None, - handles.telemetry_docs_mouse.clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .finish(); - - let toggle_crash = actions.toggle_crash_reporting.clone(); - let crash_reporting_toggle = Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) - .with_child( - Shrinkable::new( - 1., - render_privacy_settings_section_header("Send crash reports", appearance).finish(), - ) - .finish(), - ) - .with_child( - appearance - .ui_builder() - .switch(handles.crash_reporting_switch.clone()) - .check(PrivacySettings::as_ref(app).is_crash_reporting_enabled) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(toggle_crash.clone()); - }) - .finish(), - ) - .finish(); - - let crash_reporting_description = render_description( - appearance, - "Crash reporting helps Warp's engineering team understand stability and improve performance.".into(), - ); - - let toggle_cloud = actions.toggle_cloud_conversation_storage.clone(); - let cloud_conversation_storage_toggle = Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) - .with_child( - Shrinkable::new( - 1., - render_privacy_settings_section_header( - "Store AI conversations in the cloud", - appearance, - ) - .finish(), - ) - .finish(), - ) - .with_child( - appearance - .ui_builder() - .switch(handles.cloud_conversation_storage_switch.clone()) - .check(PrivacySettings::as_ref(app).is_cloud_conversation_storage_enabled) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(toggle_cloud.clone()); - }) - .finish(), - ) - .finish(); - - let cloud_conversation_storage_description = render_description( - appearance, - if PrivacySettings::as_ref(app).is_cloud_conversation_storage_enabled { - "Agent conversations can be shared with others and are retained when you log in on different devices. This data is only stored for product functionality, and Warp will not use it for analytics." - } else { - "Agent conversations are only stored locally on your machine, are lost upon logout, and cannot be shared. Note: conversation data for ambient agents are still stored in the cloud." - } - .into(), - ); - - let mut col = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - - // Builds without a telemetry/crash reporting config (e.g. OpenWarp) cannot - // ship the corresponding events, so the toggles would be no-ops. Hide each - // one independently based on whether its backing config is present. - if ChannelState::is_telemetry_available() && !FeatureFlag::GlobalAIAnalyticsBanner.is_enabled() - { - col.add_children(vec![ - Container::new(telemetry_toggle) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - Container::new(telemetry_description) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - Container::new(telemetry_link) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - ]); - } - - if ChannelState::is_crash_reporting_available() { - col.add_children(vec![ - Container::new(crash_reporting_toggle) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - Container::new(crash_reporting_description) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - ]); - } - - // Hide the cloud conversation storage toggle entirely when AI is disabled: - // the setting has no effect without AI, and showing it is confusing. - if FeatureFlag::CloudConversations.is_enabled() && is_ai_enabled { - col.add_children(vec![ - Container::new(cloud_conversation_storage_toggle) - .with_margin_bottom(AUTH_MODAL_GAP) - .finish(), - Container::new(cloud_conversation_storage_description) - .with_margin_bottom(20.) - .finish(), - ]); - } - - col.finish() -} diff --git a/app/src/auth/login_error_modal.rs b/app/src/auth/login_error_modal.rs deleted file mode 100644 index 0b4c5734..00000000 --- a/app/src/auth/login_error_modal.rs +++ /dev/null @@ -1,139 +0,0 @@ -use std::borrow::Cow; - -use pathfinder_color::ColorU; -use galaxyui::{ - elements::{ - Align, Border, ConstrainedBox, Container, CornerRadius, Flex, ParentElement, Shrinkable, - }, - ui_components::{ - components::{UiComponent, UiComponentStyles}, - text::Span, - }, - AppContext, Element, SingletonEntity as _, -}; - -use crate::{ - appearance::Appearance, - modal::MODAL_CORNER_RADIUS, - root_view::unthemed_window_border, - themes::theme::{Blend, Fill}, -}; - -/// A full-window login error. -/// -/// This is used for uncommon login error states, such as: -/// * A user needing to link SSO after logging in with an incorrect Firebase provider. -/// * An error importing the user from a host web application. -pub struct LoginErrorModal { - modal_styles: UiComponentStyles, - header_styles: UiComponentStyles, - header: Option>, - detail_styles: UiComponentStyles, - detail: Option>, - - action: Option>, - - window_corner_radius: CornerRadius, -} - -impl LoginErrorModal { - pub fn new(app: &AppContext) -> Self { - let appearance = Appearance::as_ref(app); - let modal_styles = UiComponentStyles { - width: Some(480.), - height: Some(280.), - border_color: Some(Fill::black().blend(&Fill::white().with_opacity(15)).into()), - border_width: Some(1.), - ..Default::default() - }; - let header_styles = UiComponentStyles { - font_family_id: Some(appearance.ui_font_family()), - font_size: Some(appearance.header_font_size()), - ..Default::default() - }; - let detail_styles = UiComponentStyles { - font_family_id: Some(appearance.ui_font_family()), - font_size: Some(appearance.ui_font_size()), - font_color: Some(appearance.theme().nonactive_ui_text_color().into()), - ..Default::default() - }; - - LoginErrorModal { - modal_styles, - header_styles, - detail_styles, - window_corner_radius: app.windows().window_corner_radius(), - header: None, - detail: None, - action: None, - } - } - - pub fn with_header(mut self, header: impl Into>) -> Self { - self.header = Some(header.into()); - self - } - - pub fn with_detail(mut self, detail: impl Into>) -> Self { - self.detail = Some(detail.into()); - self - } - - pub fn with_action(mut self, action: Box) -> Self { - self.action = Some(action); - self - } -} - -impl UiComponent for LoginErrorModal { - type ElementType = Container; - - fn build(self) -> Self::ElementType { - let mut contents = Flex::column(); - if let Some(header) = self.header { - contents.add_child( - Shrinkable::new( - 1., - Align::new(Span::new(header, self.header_styles).build().finish()).finish(), - ) - .finish(), - ); - } - if let Some(detail) = self.detail { - contents.add_child( - Shrinkable::new( - 1., - Align::new(Span::new(detail, self.detail_styles).build().finish()).finish(), - ) - .finish(), - ); - } - if let Some(action) = self.action { - contents.add_child(Shrinkable::new(1., Align::new(action).finish()).finish()); - } - let modal = Container::new( - ConstrainedBox::new(contents.finish()) - .with_width(self.modal_styles.width.unwrap_or_default()) - .with_height(self.modal_styles.height.unwrap_or_default()) - .finish(), - ) - .with_border( - Border::all(self.modal_styles.border_width.unwrap_or_default()) - .with_border_fill(self.modal_styles.border_color.unwrap_or_default()), - ) - .with_corner_radius(CornerRadius::with_all(MODAL_CORNER_RADIUS)) - .finish(); - - Container::new(Align::new(modal).finish()) - .with_background_color(ColorU::black()) - .with_corner_radius(self.window_corner_radius) - .with_border(unthemed_window_border()) - } - - fn with_style(mut self, style: UiComponentStyles) -> Self { - self.modal_styles = self.modal_styles.merge(style); - self.header_styles = self.header_styles.merge(style); - self.detail_styles = self.detail_styles.merge(style); - self - } -} diff --git a/app/src/auth/login_failure_notification.rs b/app/src/auth/login_failure_notification.rs deleted file mode 100644 index 4d4546d6..00000000 --- a/app/src/auth/login_failure_notification.rs +++ /dev/null @@ -1,167 +0,0 @@ -use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; -use galaxyui::{ - elements::{ - Border, ConstrainedBox, Container, CrossAxisAlignment, Flex, FormattedTextElement, - HighlightedHyperlink, Icon, MouseStateHandle, ParentElement, Shrinkable, - }, - ui_components::components::UiComponent, - Action, AppContext, Element, SingletonEntity, -}; - -use crate::appearance::Appearance; - -const LOGIN_TROUBLESHOOTING_DOCS_URL: &str = - "https://docs.warp.dev/support-and-community/troubleshooting-and-support/troubleshooting-login-issues"; - -/// Represents reasons why login failed. -pub enum LoginFailureReason { - InvalidRedirectUrl { was_pasted: bool }, - FailedUserAuthentication, - FailedMintCustomToken, - InvalidStateParameter, - MissingStateParameter, -} - -impl LoginFailureReason { - /// Returns an error message to be presented to the user when login fails. - pub(crate) fn to_formatted_text(&self) -> FormattedText { - fn with_troubleshooting_text( - mut fragments: Vec, - ) -> Vec { - fragments.extend([ - FormattedTextFragment::plain_text(" Not the first time? See our "), - FormattedTextFragment::hyperlink( - "troubleshooting docs", - LOGIN_TROUBLESHOOTING_DOCS_URL, - ), - FormattedTextFragment::plain_text("."), - ]); - fragments - } - let fragments = match self { - LoginFailureReason::InvalidRedirectUrl { was_pasted } => { - let text = if *was_pasted { - "An invalid auth token was entered into the modal." - } else { - "Failed to log in. Try manually copying the auth token from the \ - authentication web page and pasting into the modal." - }; - with_troubleshooting_text(vec![FormattedTextFragment::plain_text(text)]) - } - LoginFailureReason::FailedUserAuthentication => { - with_troubleshooting_text(vec![FormattedTextFragment::plain_text( - "Request to log in failed.", - )]) - } - LoginFailureReason::FailedMintCustomToken => { - with_troubleshooting_text(vec![FormattedTextFragment::plain_text( - "Request to sign up failed.", - )]) - } - LoginFailureReason::InvalidStateParameter | LoginFailureReason::MissingStateParameter => { - with_troubleshooting_text(vec![FormattedTextFragment::plain_text( - "The redirect URL pasted did not originate from this app. Please click the button below to try again.", - )]) - } - }; - FormattedText::new([FormattedTextLine::Line(fragments)]) - } -} - -/// Renders a dismissable notification with a message explaining why login failed. -pub fn render( - login_failure_reason: &LoginFailureReason, - close_notification_mouse_state: MouseStateHandle, - highlighted_hyperlink_state: HighlightedHyperlink, - dismiss_action: A, - ctx: &AppContext, -) -> Box { - let appearance = Appearance::as_ref(ctx); - - let mut notification_contents = - Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); - notification_contents.add_child( - Container::new( - ConstrainedBox::new( - Icon::new( - "bundled/svg/warning.svg", - appearance - .theme() - .main_text_color(appearance.theme().surface_2()), - ) - .finish(), - ) - .with_width(styles::NOTIFICATION_WARNING_ICON_SIZE) - .with_height(styles::NOTIFICATION_WARNING_ICON_SIZE) - .finish(), - ) - .with_margin_right(styles::NOTIFICATION_WARNING_MARGIN_RIGHT) - .finish(), - ); - notification_contents.add_child( - Shrinkable::new( - 1., - Container::new( - FormattedTextElement::new( - login_failure_reason.to_formatted_text(), - appearance.ui_font_size(), - appearance.ui_font_family(), - appearance.monospace_font_family(), - appearance - .theme() - .main_text_color(appearance.theme().surface_2()) - .into_solid(), - highlighted_hyperlink_state, - ) - .register_default_click_handlers(|url, _, ctx| { - ctx.open_url(&url.url); - }) - .finish(), - ) - .with_margin_right(styles::NOTIFICATION_MESSAGE_MARGIN_RIGHT) - .finish(), - ) - .finish(), - ); - notification_contents.add_child( - appearance - .ui_builder() - .close_button( - styles::NOTIFICATION_CLOSE_BUTTON_SIZE, - close_notification_mouse_state, - ) - .build() - .on_click(move |ctx, _, _| ctx.dispatch_typed_action(dismiss_action.clone())) - .finish(), - ); - ConstrainedBox::new( - Container::new(notification_contents.finish()) - .with_background(appearance.theme().surface_2()) - .with_corner_radius(styles::NOTIFICATION_CONTAINER_CORNER_RADIUS) - .with_border( - Border::all(styles::NOTIFICATION_BORDER_WIDTH) - .with_border_fill(appearance.theme().split_pane_border_color()), - ) - .with_uniform_padding(styles::NOTIFICATION_CONTAINER_PADDING) - .with_uniform_margin(16.) - .finish(), - ) - .with_max_width(450.) - .finish() -} - -mod styles { - use galaxyui::elements::{CornerRadius, Radius}; - - pub const NOTIFICATION_CONTAINER_PADDING: f32 = 8.; - pub const NOTIFICATION_CONTAINER_CORNER_RADIUS: CornerRadius = - CornerRadius::with_all(Radius::Pixels(4.)); - pub const NOTIFICATION_BORDER_WIDTH: f32 = 1.; - - pub const NOTIFICATION_CLOSE_BUTTON_SIZE: f32 = 24.; - - pub const NOTIFICATION_MESSAGE_MARGIN_RIGHT: f32 = 8.; - - pub const NOTIFICATION_WARNING_ICON_SIZE: f32 = 20.; - pub const NOTIFICATION_WARNING_MARGIN_RIGHT: f32 = 12.; -} diff --git a/app/src/auth/login_slide.rs b/app/src/auth/login_slide.rs index df47d138..38fd4c20 100644 --- a/app/src/auth/login_slide.rs +++ b/app/src/auth/login_slide.rs @@ -1,113 +1,11 @@ -use crate::appearance::Appearance; -use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; -use crate::auth::auth_view_modal::AuthRedirectPayload; -use crate::auth::auth_view_shared_helpers::{ - render_privacy_settings_toggles, PrivacySettingsActions, PrivacySettingsHandles, -}; -use crate::auth::login_failure_notification::{self, LoginFailureReason}; -use crate::editor::{EditorView, SingleLineEditorOptions, TextColors, TextOptions}; -use crate::server::telemetry::{LoginEventSource, TelemetryEvent}; -use crate::settings::PrivacySettings; -use crate::themes::theme::Fill as ThemeFill; -use crate::util::bindings::CustomAction; -use crate::{send_telemetry_from_ctx, send_telemetry_sync_from_ctx}; +use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext}; +use onboarding::OnboardingIntention; -use onboarding::slides::{layout, slide_content}; -use onboarding::{OnboardingIntention, AI_FEATURES, WARP_DRIVE_FEATURES}; -use pathfinder_color::ColorU; -use ui_components::{button, Component as _, Options as _}; -use galaxy_core::features::FeatureFlag; -use galaxy_core::ui::theme::color::internal_colors; -use galaxy_core::ui::Icon; -use galaxyui::clipboard::ClipboardContent; -use galaxyui::elements::{ - Align, Border, CacheOption, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, - CrossAxisAlignment, Dismiss, Fill, Flex, FormattedTextElement, HighlightedHyperlink, Image, - MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentElement, Radius, - Shrinkable, Stack, -}; -use galaxyui::fonts::Weight; -use galaxyui::keymap::{FixedBinding, Keystroke}; -use galaxyui::text_layout::TextAlignment; -use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; -use galaxyui::{ - actions::StandardAction, AppContext, Element, Entity, FocusContext, SingletonEntity, - TypedActionView, UpdateModel, View, ViewContext, ViewHandle, -}; - -use std::cell::Cell; - -use pathfinder_geometry::vector::vec2f; -use galaxyui::elements::{ChildAnchor, ParentAnchor, ParentOffsetBounds}; - -const TOS_URL: &str = "https://www.warp.dev/terms-of-service"; - -// --------------------------------------------------------------------------- -// Init (keybindings) -// --------------------------------------------------------------------------- - -pub fn init(app: &mut AppContext) { - use galaxyui::keymap::macros::*; - - app.register_fixed_bindings([ - FixedBinding::new( - "enter", - LoginSlideAction::Enter, - id!(LoginSlideView::ui_name()), - ), - FixedBinding::new( - "cmdorctrl-enter", - LoginSlideAction::ShowSkipDialog, - id!(LoginSlideView::ui_name()), - ), - FixedBinding::new( - "escape", - LoginSlideAction::DismissOverlayOrBack, - id!(LoginSlideView::ui_name()), - ), - FixedBinding::custom( - CustomAction::Paste, - LoginSlideAction::PasteAuthUrl, - "Paste", - id!(LoginSlideView::ui_name()), - ), - FixedBinding::standard( - StandardAction::Paste, - LoginSlideAction::PasteAuthUrl, - id!(LoginSlideView::ui_name()), - ), - ]); - - #[cfg(any(target_os = "linux", target_os = "windows"))] - app.register_fixed_bindings([FixedBinding::new( - "cmdorctrl-v", - LoginSlideAction::PasteAuthUrl, - id!(LoginSlideView::ui_name()), - )]); -} - -// --------------------------------------------------------------------------- -// Actions & Events -// --------------------------------------------------------------------------- - -#[derive(Clone, Copy, Debug)] -pub enum LoginSlideAction { - Enter, - ShowSkipDialog, - ConfirmSkip, - DismissDialog, - DismissOverlayOrBack, - Back, - BackToSelectAuthPathway, - CopyLoginUrl, - EnterToken, - ShowPrivacySettings, - HideOverlay, - ToggleTelemetry, - ToggleCrashReporting, - ToggleCloudConversationStorage, - DismissNotification, - PasteAuthUrl, +#[derive(Clone, Debug)] +pub enum LoginSlideSource { + OnboardingFlow, + PrivacySettingsFromTerminalIntentionTheme, + LoginExistingUserFromWelcome, } #[derive(Clone, Debug)] @@ -116,965 +14,25 @@ pub enum LoginSlideEvent { LoginLaterConfirmed, } -/// How the user arrived at the login slide. Controls which step is shown first -/// and how "Back" is routed when the user backs out of the privacy-settings step. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum LoginSlideSource { - /// Reached via the normal onboarding flow (e.g. agent intention requires an account). - OnboardingFlow, - /// Reached via the "Log in" link on the intro / welcome slide. - LoginExistingUserFromWelcome, - /// Reached via the "Privacy Settings" link on the terminal-intention theme slide. - /// Starts directly in the privacy settings step and routes Back to onboarding. - PrivacySettingsFromTerminalIntentionTheme, -} - -// --------------------------------------------------------------------------- -// Login step -// --------------------------------------------------------------------------- - -enum LoginStep { - SelectAuthPathway, - BrowserOpen, - PrivacySettings, -} - -// --------------------------------------------------------------------------- -// Overlay -// --------------------------------------------------------------------------- - -#[derive(Copy, Clone, Debug)] -enum LoginSlideOverlay { - SkipDialog, -} - -// --------------------------------------------------------------------------- -// View -// --------------------------------------------------------------------------- - -const AUTH_TOKEN_INPUT_BORDER_RADIUS: Radius = Radius::Pixels(4.); - -pub struct LoginSlideView { - /// Whether AI will be enabled once onboarding is applied. Used to hide the - /// cloud-conversation-storage toggle in the privacy settings step when the - /// user has disabled Warp Agent during onboarding (or is on the terminal - /// intention path, which disables AI). The actual `AISettings` value may - /// not have been written yet at this point, since onboarding settings are - /// applied after login. - ai_enabled: bool, - /// Onboarding intention selected by the user, used to render Drive-focused - /// copy on the Terminal+Drive path. On the login slide, `intention == - /// OnboardingIntention::Terminal` is equivalent to "Terminal+Drive": - /// `RootView` only routes Terminal-intent users here when Warp Drive is - /// enabled. - intention: OnboardingIntention, - theme_visual_path: &'static str, - step: LoginStep, - active_overlay: Option, - last_login_failure_reason: Option, - source: LoginSlideSource, - - // Auth token input (browser-open step) - auth_token_input: ViewHandle, - show_auth_token_input: bool, - - // Buttons - back_button: button::Button, - skip_button: button::Button, - login_button: button::Button, - browser_back_button: button::Button, - done_button: button::Button, - dialog_login_button: button::Button, - dialog_skip_button: button::Button, - dialog_close_button: button::Button, - - // Mouse states for links - tos_mouse_state: MouseStateHandle, - privacy_settings_mouse_state: MouseStateHandle, - copy_url_mouse_state: MouseStateHandle, - enter_token_mouse_state: MouseStateHandle, - - // Privacy settings overlay (shared with AuthViewBody) - privacy_settings_handles: PrivacySettingsHandles, - - scroll_state: ClippedScrollStateHandle, - close_login_notification_mouse_state: MouseStateHandle, - highlighted_hyperlink_state: HighlightedHyperlink, -} - -/// All image paths used by the login slide visual. These mirror the set in -/// `ThemePickerSlide::VISUAL_IMAGE_PATHS` so the login slide can keep showing -/// the same themed right panel the user was looking at on the theme slide. -const VISUAL_IMAGE_PATHS: &[&str] = &[ - // Terminal intention - "async/png/onboarding/terminal_intention/theme/theme_phenomenon_vertical.png", - "async/png/onboarding/terminal_intention/theme/theme_phenomenon_horizontal.png", - "async/png/onboarding/terminal_intention/theme/theme_dark_vertical.png", - "async/png/onboarding/terminal_intention/theme/theme_dark_horizontal.png", - "async/png/onboarding/terminal_intention/theme/theme_light_vertical.png", - "async/png/onboarding/terminal_intention/theme/theme_light_horizontal.png", - "async/png/onboarding/terminal_intention/theme/theme_adeberry_vertical.png", - "async/png/onboarding/terminal_intention/theme/theme_adeberry_horizontal.png", - // Agent intention - "async/png/onboarding/agent_intention/theme/theme_phenomenon_vertical.png", - "async/png/onboarding/agent_intention/theme/theme_phenomenon_horizontal.png", - "async/png/onboarding/agent_intention/theme/theme_dark_vertical.png", - "async/png/onboarding/agent_intention/theme/theme_dark_horizontal.png", - "async/png/onboarding/agent_intention/theme/theme_light_vertical.png", - "async/png/onboarding/agent_intention/theme/theme_light_horizontal.png", - "async/png/onboarding/agent_intention/theme/theme_adeberry_vertical.png", - "async/png/onboarding/agent_intention/theme/theme_adeberry_horizontal.png", -]; - -fn resolve_visual_path( - intention: OnboardingIntention, - theme_name: &str, - use_vertical_tabs: bool, -) -> &'static str { - let intention_dir = match intention { - OnboardingIntention::AgentDrivenDevelopment => "agent_intention", - OnboardingIntention::Terminal => "terminal_intention", - }; - let name_key = match theme_name { - "Phenomenon" => "phenomenon", - "Dark" => "dark", - "Light" => "light", - "Adeberry" => "adeberry", - _ => "dark", - }; - let orientation = if use_vertical_tabs { - "vertical" - } else { - "horizontal" - }; - VISUAL_IMAGE_PATHS - .iter() - .find(|p| p.contains(intention_dir) && p.contains(name_key) && p.contains(orientation)) - .unwrap_or(&VISUAL_IMAGE_PATHS[0]) -} +pub struct LoginSlideView; impl LoginSlideView { - /// Whether the auth token input editor is currently rendered and should be focusable. - /// This is only true on the BrowserOpen step after the user clicks to paste their token. - pub fn is_auth_token_input_visible(&self) -> bool { - matches!(self.step, LoginStep::BrowserOpen) && self.show_auth_token_input - } - pub fn new( - ai_enabled: bool, - theme_name: &str, - use_vertical_tabs: bool, - intention: OnboardingIntention, - source: LoginSlideSource, - ctx: &mut ViewContext, + _ai_enabled: bool, + _theme_name: &str, + _use_vertical_tabs: bool, + _intention: OnboardingIntention, + _source: LoginSlideSource, + _ctx: &mut ViewContext, ) -> Self { - let auth_manager = AuthManager::handle(ctx); - ctx.subscribe_to_model(&auth_manager, |me, _, event, ctx| { - me.handle_auth_manager_event(event, ctx); - }); - - let auth_token_input = ctx.add_typed_action_view(|ctx| { - let appearance = Appearance::as_ref(ctx); - let text_color = ThemeFill::Solid(ColorU::black()); - let mut editor = EditorView::single_line( - SingleLineEditorOptions { - text: TextOptions { - font_size_override: Some(12.), - font_family_override: Some(appearance.ui_font_family()), - text_colors_override: Some(TextColors { - default_color: text_color, - disabled_color: text_color.with_opacity(20), - hint_color: text_color.with_opacity(40), - }), - ..Default::default() - }, - soft_wrap: false, - ..Default::default() - }, - ctx, - ); - editor.set_placeholder_text("Auth Token", ctx); - editor - }); - - ctx.subscribe_to_view(&auth_token_input, |me, _, event, ctx| { - use crate::editor::Event::{AltEnter, CmdEnter, Enter, Paste, ShiftEnter}; - match event { - AltEnter | CmdEnter | Enter | Paste | ShiftEnter => { - let text = me.auth_token_input.as_ref(ctx).buffer_text(ctx); - me.handle_pasted_auth_url(text, ctx); - } - _ => {} - }; - ctx.notify(); - }); - - Self { - ai_enabled, - intention, - theme_visual_path: resolve_visual_path(intention, theme_name, use_vertical_tabs), - step: match source { - LoginSlideSource::OnboardingFlow => LoginStep::SelectAuthPathway, - LoginSlideSource::LoginExistingUserFromWelcome => LoginStep::BrowserOpen, - LoginSlideSource::PrivacySettingsFromTerminalIntentionTheme => { - LoginStep::PrivacySettings - } - }, - active_overlay: None, - last_login_failure_reason: None, - source, - auth_token_input, - show_auth_token_input: false, - back_button: button::Button::default(), - skip_button: button::Button::default(), - login_button: button::Button::default(), - browser_back_button: button::Button::default(), - done_button: button::Button::default(), - dialog_login_button: button::Button::default(), - dialog_skip_button: button::Button::default(), - dialog_close_button: button::Button::default(), - tos_mouse_state: MouseStateHandle::default(), - privacy_settings_mouse_state: MouseStateHandle::default(), - copy_url_mouse_state: MouseStateHandle::default(), - enter_token_mouse_state: MouseStateHandle::default(), - privacy_settings_handles: PrivacySettingsHandles::default(), - scroll_state: ClippedScrollStateHandle::new(), - close_login_notification_mouse_state: MouseStateHandle::default(), - highlighted_hyperlink_state: HighlightedHyperlink::default(), - } + Self } - // ------------------------------------------------------------------ - // Auth manager - // ------------------------------------------------------------------ - - fn handle_auth_manager_event(&mut self, event: &AuthManagerEvent, ctx: &mut ViewContext) { - match event { - AuthManagerEvent::AuthFailed(err) => { - use crate::server::server_api::auth::UserAuthenticationError; - if let UserAuthenticationError::InvalidStateParameter = err { - self.last_login_failure_reason = - Some(LoginFailureReason::InvalidStateParameter); - } else if let UserAuthenticationError::MissingStateParameter = err { - self.last_login_failure_reason = - Some(LoginFailureReason::MissingStateParameter); - } else { - self.last_login_failure_reason = - Some(LoginFailureReason::FailedUserAuthentication); - } - } - AuthManagerEvent::CreateAnonymousUserFailed => { - self.last_login_failure_reason = Some(LoginFailureReason::FailedUserAuthentication); - } - AuthManagerEvent::MintCustomTokenFailed(_) => { - self.last_login_failure_reason = Some(LoginFailureReason::FailedMintCustomToken); - } - _ => {} - } - ctx.notify(); - } - - fn handle_pasted_auth_url(&mut self, pasted_url: String, ctx: &mut ViewContext) { - match AuthRedirectPayload::from_raw_url(pasted_url) { - Ok(redirect_payload) => { - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.initialize_user_from_auth_payload(redirect_payload, true, ctx); - }); - } - Err(error) => { - log::error!("Failed to parse AuthRedirectPayload from redirect URL: {error:#}"); - self.last_login_failure_reason = - Some(LoginFailureReason::InvalidRedirectUrl { was_pasted: true }); - } - } - ctx.notify(); - } - - fn handle_login_later(&mut self, ctx: &mut ViewContext) { - // Send synchronously since this is an important event in the sign up funnel and we - // don't want to lose events if the user quits before the event queue is flushed. - send_telemetry_sync_from_ctx!( - TelemetryEvent::LoginLaterConfirmationButtonClicked { - source: LoginEventSource::OnboardingSlide, - }, - ctx - ); - if FeatureFlag::SkipFirebaseAnonymousUser.is_enabled() { - AuthManager::handle(ctx).update(ctx, |_, ctx| { - ctx.emit(AuthManagerEvent::SkippedLogin); - }); - } else { - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.create_anonymous_user(None, ctx); - }); - } - ctx.emit(LoginSlideEvent::LoginLaterConfirmed); - } - - // ------------------------------------------------------------------ - // Rendering — main layout - // ------------------------------------------------------------------ - - fn render_content( - &self, - appearance: &Appearance, - app: &AppContext, - editor_rendered: &Cell, - ) -> Box { - match self.step { - LoginStep::SelectAuthPathway => { - let children = self.render_select_auth_content(appearance); - let bottom_nav = self.render_select_auth_bottom_nav(appearance); - slide_content::onboarding_slide_content( - children, - bottom_nav, - self.scroll_state.clone(), - appearance, - ) - } - LoginStep::BrowserOpen => { - let children = self.render_browser_open_content(appearance, editor_rendered); - let bottom_nav = self.render_browser_open_bottom_nav(appearance); - slide_content::onboarding_slide_content( - children, - bottom_nav, - self.scroll_state.clone(), - appearance, - ) - } - LoginStep::PrivacySettings => { - let children = self.render_privacy_settings_content(appearance, app); - let bottom_nav = self.render_privacy_settings_bottom_nav(appearance); - slide_content::onboarding_slide_content( - children, - bottom_nav, - self.scroll_state.clone(), - appearance, - ) - } - } - } - - // ------------------------------------------------------------------ - // Step 1: Select auth pathway - // ------------------------------------------------------------------ - - /// Disclaimer prefix shown before the "Privacy Settings" link. AI is - /// dropped from the wording on paths that don't enable AI (e.g. - /// Terminal+Drive), since there are no AI features to opt out of there. - fn privacy_disclaimer_prefix(&self) -> &'static str { - if self.ai_enabled { - "If you'd like to opt out of analytics and AI features, you can adjust your " - } else { - "If you'd like to opt out of analytics, you can adjust your " - } - } - - fn render_select_auth_content(&self, appearance: &Appearance) -> Vec> { - let theme = appearance.theme(); - let sub_text_color = internal_colors::text_sub(theme, theme.background().into_solid()); - let ui_builder = appearance.ui_builder(); - - let is_terminal = matches!(self.intention, OnboardingIntention::Terminal); - let title_text = if is_terminal { - "Get started with Warp Drive" - } else { - "Get started with AI" - }; - let title = FormattedTextElement::from_str(title_text, appearance.ui_font_family(), 36.) - .with_color(internal_colors::text_main( - theme, - theme.background().into_solid(), - )) - .with_weight(Weight::Medium) - .with_alignment(TextAlignment::Left) - .finish(); - - let subtitle_text = if is_terminal { - "Connect your account to save and share notebooks, workflows, and more across devices." - } else { - "Connect your account to enable AI-powered planning, coding, and automation." - }; - let subtitle = - FormattedTextElement::from_str(subtitle_text, appearance.ui_font_family(), 16.) - .with_color(sub_text_color) - .with_weight(Weight::Normal) - .with_alignment(TextAlignment::Left) - .with_line_height_ratio(1.0) - .finish(); - - // TOS and Privacy links - let disclaimer_styles = UiComponentStyles { - font_color: Some(sub_text_color), - font_size: Some(12.), - ..Default::default() - }; - - let tos_line = Flex::row() - .with_child( - ui_builder - .span("By continuing, you agree to Warp's ") - .with_style(disclaimer_styles) - .build() - .finish(), - ) - .with_child( - ui_builder - .link( - "Terms of Service".into(), - Some(TOS_URL.into()), - None, - self.tos_mouse_state.clone(), - ) - .soft_wrap(false) - .with_style(UiComponentStyles { - font_size: Some(12.), - ..Default::default() - }) - .build() - .finish(), - ) - .finish(); - - let privacy_line = Flex::row() - .with_child( - ui_builder - .span(self.privacy_disclaimer_prefix()) - .with_style(disclaimer_styles) - .build() - .finish(), - ) - .with_child( - ui_builder - .link( - "Privacy Settings".into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action(LoginSlideAction::ShowPrivacySettings); - })), - self.privacy_settings_mouse_state.clone(), - ) - .soft_wrap(false) - .with_style(UiComponentStyles { - font_size: Some(12.), - ..Default::default() - }) - .build() - .finish(), - ) - .finish(); - - let disclaimers = Container::new( - Flex::column() - .with_child(privacy_line) - .with_child(Container::new(tos_line).with_margin_top(8.).finish()) - .finish(), - ) - .with_margin_top(24.) - .finish(); - - let header = Flex::column() - .with_main_axis_size(MainAxisSize::Min) - .with_cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(title) - .with_child(Container::new(subtitle).with_margin_top(16.).finish()) - .with_child(disclaimers) - .finish(); - - vec![header] - } - - fn render_select_auth_bottom_nav(&self, appearance: &Appearance) -> Box { - let back_button = self.back_button.render( - appearance, - button::Params { - content: button::Content::Label("Back".into()), - theme: &button::themes::Naked, - options: button::Options { - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(LoginSlideAction::Back); - })), - ..button::Options::default(appearance) - }, - }, - ); - - let cmd_enter = Keystroke::parse("cmdorctrl-enter").unwrap_or_default(); - let skip_label = if matches!(self.intention, OnboardingIntention::Terminal) { - "Disable Warp Drive" - } else { - "Disable AI features" - }; - let skip_button = self.skip_button.render( - appearance, - button::Params { - content: button::Content::Label(skip_label.into()), - theme: &button::themes::Naked, - options: button::Options { - keystroke: Some(cmd_enter), - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(LoginSlideAction::ShowSkipDialog); - })), - ..button::Options::default(appearance) - }, - }, - ); - - let enter = Keystroke::parse("enter").unwrap_or_default(); - let login_button = self.login_button.render( - appearance, - button::Params { - content: button::Content::Label("Continue".into()), - theme: &button::themes::Primary, - options: button::Options { - keystroke: Some(enter), - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(LoginSlideAction::Enter); - })), - ..button::Options::default(appearance) - }, - }, - ); - - let right_buttons = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(skip_button) - .with_child(Container::new(login_button).with_margin_left(4.).finish()) - .finish(); - - Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(back_button) - .with_child(right_buttons) - .finish() - } - - // ------------------------------------------------------------------ - // Step 2: Browser open - // ------------------------------------------------------------------ - - fn render_browser_open_content( - &self, - appearance: &Appearance, - editor_rendered: &Cell, - ) -> Vec> { - let theme = appearance.theme(); - let sub_text_color = internal_colors::text_sub(theme, theme.background().into_solid()); - let ui_builder = appearance.ui_builder(); - - let sub_text_styles = UiComponentStyles { - font_color: Some(sub_text_color), - ..Default::default() - }; - - let title = FormattedTextElement::from_str( - "Sign in on your browser to continue", - appearance.ui_font_family(), - 36., - ) - .with_color(internal_colors::text_main( - theme, - theme.background().into_solid(), - )) - .with_weight(Weight::Medium) - .with_alignment(TextAlignment::Left) - .finish(); - - let hint = Flex::column() - .with_child( - Flex::row() - .with_child( - ui_builder - .span("If your browser hasn't launched, ") - .with_style(sub_text_styles) - .build() - .finish(), - ) - .with_child( - ui_builder - .link( - "copy the URL".into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action(LoginSlideAction::CopyLoginUrl); - })), - self.copy_url_mouse_state.clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .with_child( - ui_builder - .span(" and open") - .with_style(sub_text_styles) - .build() - .finish(), - ) - .finish(), - ) - .with_child( - ui_builder - .span("the page manually.") - .with_style(sub_text_styles) - .build() - .finish(), - ) - .finish(); - - // Auth token: show either the "Click here" link or the input box. - // When showing the input, we use `editor_rendered` (a Cell passed - // from render()) so the ChildView is only created on the FIRST call of - // this closure. static_left calls the left-content closure twice (for - // narrow and wide layouts); creating two ChildViews for the same editor - // breaks focus/event dispatch. - let auth_token: Box = if self.show_auth_token_input { - if editor_rendered.get() { - // Second call (two-column layout, the default): render the real editor. - ui_builder - .text_input(self.auth_token_input.clone()) - .with_style(UiComponentStyles { - background: Some(Fill::Solid(ColorU::white())), - border_width: Some(0.), - border_radius: Some(CornerRadius::with_all(AUTH_TOKEN_INPUT_BORDER_RADIUS)), - padding: Some(Coords { - top: 12., - bottom: 12., - left: 16., - right: 16., - }), - margin: Some(Coords { - top: 8., - bottom: 0., - left: 0., - right: 0., - }), - ..Default::default() - }) - .build() - .finish() - } else { - // First call (narrow layout fallback): placeholder. - editor_rendered.set(true); - Container::new(galaxyui::elements::Empty::new().finish()) - .with_padding_top(12.) - .with_padding_bottom(12.) - .with_padding_left(16.) - .with_padding_right(16.) - .with_margin_top(8.) - .finish() - } - } else { - Flex::row() - .with_child( - ui_builder - .link( - "Click here to paste your token from the browser".into(), - None, - Some(Box::new(|ctx| { - ctx.dispatch_typed_action(LoginSlideAction::EnterToken); - })), - self.enter_token_mouse_state.clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ) - .finish() - }; - - let header = Flex::column() - .with_main_axis_size(MainAxisSize::Min) - .with_cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(title) - .with_child(Container::new(hint).with_margin_top(16.).finish()) - .with_child(Container::new(auth_token).with_margin_top(16.).finish()) - .finish(); - - vec![header] - } - - fn render_browser_open_bottom_nav(&self, appearance: &Appearance) -> Box { - let back_button = self.browser_back_button.render( - appearance, - button::Params { - content: button::Content::Label("Back".into()), - theme: &button::themes::Naked, - options: button::Options { - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(LoginSlideAction::BackToSelectAuthPathway); - })), - ..button::Options::default(appearance) - }, - }, - ); - - Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_child(back_button) - .finish() - } - - // ------------------------------------------------------------------ - // Step 3: Privacy settings (inline in left column) - // ------------------------------------------------------------------ - - fn render_privacy_settings_content( - &self, - appearance: &Appearance, - app: &AppContext, - ) -> Vec> { - let theme = appearance.theme(); - - let title = - FormattedTextElement::from_str("Privacy Settings", appearance.ui_font_family(), 36.) - .with_color(internal_colors::text_main( - theme, - theme.background().into_solid(), - )) - .with_weight(Weight::Medium) - .with_alignment(TextAlignment::Left) - .finish(); - - let actions = PrivacySettingsActions { - toggle_telemetry: LoginSlideAction::ToggleTelemetry, - toggle_crash_reporting: LoginSlideAction::ToggleCrashReporting, - toggle_cloud_conversation_storage: LoginSlideAction::ToggleCloudConversationStorage, - hide_overlay: LoginSlideAction::HideOverlay, - }; - - let toggles = render_privacy_settings_toggles( - appearance, - app, - &self.privacy_settings_handles, - &actions, - self.ai_enabled, - ); - - vec![title, Container::new(toggles).with_margin_top(24.).finish()] - } - - fn render_privacy_settings_bottom_nav(&self, appearance: &Appearance) -> Box { - let back_button = self.done_button.render( - appearance, - button::Params { - content: button::Content::Label("Back".into()), - theme: &button::themes::Naked, - options: button::Options { - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(LoginSlideAction::HideOverlay); - })), - ..button::Options::default(appearance) - }, - }, - ); - - Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_child(back_button) - .finish() - } - - // ------------------------------------------------------------------ - // Visual - // ------------------------------------------------------------------ - - fn render_visual(&self) -> Box { - let path = self.theme_visual_path; - layout::onboarding_right_panel_with_bg(path, layout::FOREGROUND_LAYOUT_DEFAULT) - } - - // ------------------------------------------------------------------ - // Rendering — skip confirmation dialog - // ------------------------------------------------------------------ - - fn render_skip_dialog(&self, appearance: &Appearance) -> Box { - let theme = appearance.theme(); - let dialog_surface = theme.surface_1(); - let dialog_surface_solid = dialog_surface.into_solid(); - let border_color = internal_colors::neutral_4(theme); - - let is_terminal = matches!(self.intention, OnboardingIntention::Terminal); - let title_text = if is_terminal { - "Are you sure you want to disable Warp Drive?" - } else { - "Are you sure you want to disable AI features?" - }; - let title = FormattedTextElement::from_str(title_text, appearance.ui_font_family(), 16.) - .with_color(internal_colors::text_main(theme, dialog_surface_solid)) - .with_weight(Weight::Bold) - .with_line_height_ratio(1.25) - .finish(); - - // Close button with ESC keyboard-shortcut badge. - let escape = Keystroke::parse("escape").unwrap_or_default(); - let close_button = self.dialog_close_button.render( - appearance, - button::Params { - content: button::Content::Icon(Icon::X), - theme: &button::themes::Naked, - options: button::Options { - keystroke: Some(escape), - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(LoginSlideAction::DismissDialog); - })), - ..button::Options::default(appearance) - }, - }, - ); - - let title_row = Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) - .with_cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(Shrinkable::new(1., title).finish()) - .with_child(close_button) - .finish(); - - let body_text_str = if is_terminal { - "Warp Drive lets you save workflows and knowledge across devices and share them with your team. By continuing, you won't have access to the following features:" - } else { - "Warp is better with AI. By continuing, you won't have access to any of the following features:" - }; - let body_text = - FormattedTextElement::from_str(body_text_str, appearance.ui_font_family(), 14.) - .with_color(internal_colors::text_main(theme, dialog_surface_solid)) - .with_weight(Weight::Normal) - .with_line_height_ratio(1.2) - .finish(); - - let feature_row_color: ColorU = theme.foreground().into(); - let feature_x_fill: ThemeFill = ThemeFill::Solid(theme.ansi_fg_red()); - let mut feature_list = - Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - let feature_items: &[&str] = if is_terminal { - WARP_DRIVE_FEATURES - } else { - AI_FEATURES - }; - for &item in feature_items { - let icon_el = ConstrainedBox::new(Icon::X.to_galaxyui_icon(feature_x_fill).finish()) - .with_width(16.) - .with_height(16.) - .finish(); - let text_el = FormattedTextElement::from_str(item, appearance.ui_font_family(), 14.) - .with_color(feature_row_color) - .with_weight(Weight::Normal) - .with_alignment(TextAlignment::Left) - .with_line_height_ratio(1.0) - .finish(); - let row = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(icon_el) - .with_child(Container::new(text_el).with_margin_left(4.).finish()) - .finish(); - feature_list = feature_list.with_child( - Container::new(row) - .with_padding_top(4.) - .with_padding_bottom(4.) - .finish(), - ); - } - - let body_section = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(body_text) - .with_child( - Container::new(feature_list.finish()) - .with_margin_top(12.) - .finish(), - ) - .finish(); - - let cancel_label = if is_terminal { - "Enable Warp Drive" - } else { - "Enable AI features" - }; - let login_button = self.dialog_login_button.render( - appearance, - button::Params { - content: button::Content::Label(cancel_label.into()), - theme: &button::themes::Naked, - options: button::Options { - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(LoginSlideAction::DismissDialog); - })), - ..button::Options::default(appearance) - }, - }, - ); - - let dialog_enter = Keystroke::parse("enter").unwrap_or_default(); - let skip_confirm_button = self.dialog_skip_button.render( - appearance, - button::Params { - content: button::Content::Label("Skip for now".into()), - theme: &button::themes::Primary, - options: button::Options { - keystroke: Some(dialog_enter), - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(LoginSlideAction::ConfirmSkip); - })), - ..button::Options::default(appearance) - }, - }, - ); - - let footer = Container::new( - Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::End) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(login_button) - .with_child( - Container::new(skip_confirm_button) - .with_margin_left(8.) - .finish(), - ) - .finish(), - ) - .with_border(Border::top(1.).with_border_color(border_color)) - .with_horizontal_padding(24.) - .with_vertical_padding(12.) - .finish(); - - let dialog = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child( - Container::new(title_row) - .with_horizontal_padding(24.) - .with_padding_top(24.) - .with_padding_bottom(12.) - .finish(), - ) - .with_child( - Container::new(body_section) - .with_horizontal_padding(24.) - .with_padding_bottom(16.) - .finish(), - ) - .with_child(footer) - .finish(); - - ConstrainedBox::new( - Container::new(dialog) - .with_background(dialog_surface) - .with_border(Border::all(1.).with_border_color(border_color)) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) - .finish(), - ) - .with_width(460.) - .finish() + pub fn is_auth_token_input_visible(&self) -> bool { + false } } -// --------------------------------------------------------------------------- -// Entity / View / TypedActionView -// --------------------------------------------------------------------------- - impl Entity for LoginSlideView { type Event = LoginSlideEvent; } @@ -1084,268 +42,12 @@ impl View for LoginSlideView { "LoginSlideView" } - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() { - ctx.notify(); - } - } - - fn render(&self, app: &AppContext) -> Box { - let appearance = Appearance::as_ref(app); - let theme = appearance.theme(); - - let mut stack = Stack::new(); - - // Background (same as onboarding parent) - if let Some(img) = theme.background_image() { - stack.add_child( - Shrinkable::new( - 1., - Image::new(img.source(), CacheOption::Original) - .cover() - .finish(), - ) - .finish(), - ); - let overlay_opacity = (100u8).saturating_sub(img.opacity); - stack.add_child( - galaxyui::elements::Rect::new() - .with_background(theme.background().with_opacity(overlay_opacity)) - .finish(), - ); - } else { - stack.add_child( - Container::new(galaxyui::elements::Empty::new().finish()) - .with_background(theme.background()) - .finish(), - ); - } - - // Two-column slide layout - // static_left calls the left closure twice (narrow + wide). We use a - // Cell so the editor ChildView is only created once. - let editor_rendered = Cell::new(false); - let slide = layout::static_left( - || self.render_content(appearance, app, &editor_rendered), - || self.render_visual(), - ); - stack.add_child(slide); - - // Skip dialog overlay - if matches!(self.active_overlay, Some(LoginSlideOverlay::SkipDialog)) { - let dialog = self.render_skip_dialog(appearance); - let centered = Align::new(dialog).finish(); - stack.add_child( - Dismiss::new(centered) - .on_dismiss(|ctx, _app| { - ctx.dispatch_typed_action(LoginSlideAction::DismissDialog); - }) - .finish(), - ); - } - - // Login failure notification - if let Some(login_failure_reason) = &self.last_login_failure_reason { - let notification = login_failure_notification::render( - login_failure_reason, - self.close_login_notification_mouse_state.clone(), - self.highlighted_hyperlink_state.clone(), - LoginSlideAction::DismissNotification, - app, - ); - stack.add_positioned_overlay_child( - notification, - OffsetPositioning::offset_from_parent( - vec2f(0., 40.), - ParentOffsetBounds::ParentBySize, - ParentAnchor::TopMiddle, - ChildAnchor::TopMiddle, - ), - ); - } - - stack.finish() + fn render(&self, _app: &AppContext) -> Box { + galaxyui::elements::Empty::new().finish() } } impl TypedActionView for LoginSlideView { - type Action = LoginSlideAction; - - fn handle_action(&mut self, action: &LoginSlideAction, ctx: &mut ViewContext) { - match action { - LoginSlideAction::Enter => { - // When the skip dialog is open, Enter should confirm skip instead. - if self.active_overlay.is_some() { - self.active_overlay = None; - self.handle_login_later(ctx); - return; - } - // Otherwise Enter is log in - send_telemetry_from_ctx!( - TelemetryEvent::LoginButtonClicked { - source: LoginEventSource::OnboardingSlide, - }, - ctx - ); - self.last_login_failure_reason = None; - self.step = LoginStep::BrowserOpen; - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - let sign_up_url = auth_manager.sign_up_url(); - ctx.open_url(&sign_up_url); - }); - ctx.notify(); - } - LoginSlideAction::ShowSkipDialog => { - send_telemetry_from_ctx!( - TelemetryEvent::LoginLaterButtonClicked { - source: LoginEventSource::OnboardingSlide, - }, - ctx - ); - self.active_overlay = Some(LoginSlideOverlay::SkipDialog); - ctx.notify(); - } - LoginSlideAction::ConfirmSkip => { - self.active_overlay = None; - self.handle_login_later(ctx); - } - LoginSlideAction::DismissDialog => { - self.active_overlay = None; - ctx.notify(); - } - LoginSlideAction::DismissOverlayOrBack => { - if self.active_overlay.is_some() { - self.active_overlay = None; - ctx.notify(); - } else if matches!(self.step, LoginStep::PrivacySettings) { - match self.source { - LoginSlideSource::PrivacySettingsFromTerminalIntentionTheme => { - ctx.emit(LoginSlideEvent::BackToOnboarding); - } - LoginSlideSource::OnboardingFlow - | LoginSlideSource::LoginExistingUserFromWelcome => { - self.step = LoginStep::SelectAuthPathway; - ctx.focus_self(); - ctx.notify(); - } - } - } else if matches!(self.step, LoginStep::BrowserOpen) { - // PrivacySettingsFromTerminalIntentionTheme starts on the - // privacy-settings step and should never transition into the - // select-auth-pathway step. If this branch is ever reached - // for that source, route back to onboarding instead. - match self.source { - LoginSlideSource::LoginExistingUserFromWelcome - | LoginSlideSource::PrivacySettingsFromTerminalIntentionTheme => { - ctx.emit(LoginSlideEvent::BackToOnboarding); - } - LoginSlideSource::OnboardingFlow => { - self.step = LoginStep::SelectAuthPathway; - ctx.focus_self(); - ctx.notify(); - } - } - } else { - ctx.emit(LoginSlideEvent::BackToOnboarding); - } - } - LoginSlideAction::Back => { - ctx.emit(LoginSlideEvent::BackToOnboarding); - } - LoginSlideAction::BackToSelectAuthPathway => match self.source { - // PrivacySettingsFromTerminalIntentionTheme only ever shows the - // privacy-settings step; treat "back" the same as login-from- - // welcome and return to onboarding rather than falling through - // to a step this source was designed to skip. - LoginSlideSource::LoginExistingUserFromWelcome - | LoginSlideSource::PrivacySettingsFromTerminalIntentionTheme => { - ctx.emit(LoginSlideEvent::BackToOnboarding); - } - LoginSlideSource::OnboardingFlow => { - self.step = LoginStep::SelectAuthPathway; - ctx.focus_self(); - ctx.notify(); - } - }, - LoginSlideAction::CopyLoginUrl => { - AuthManager::handle(ctx).update(ctx, |auth_manager, inner_ctx| { - let sign_in_url = auth_manager.sign_in_url(); - inner_ctx.clipboard().write(ClipboardContent { - plain_text: sign_in_url.clone(), - paths: Some(vec![sign_in_url]), - ..Default::default() - }); - }); - } - LoginSlideAction::EnterToken => { - self.auth_token_input - .update(ctx, |editor, ctx| editor.paste(ctx)); - self.show_auth_token_input = true; - ctx.notify(); - } - LoginSlideAction::ShowPrivacySettings => { - send_telemetry_sync_from_ctx!( - TelemetryEvent::OpenAuthPrivacySettings { - source: LoginEventSource::OnboardingSlide, - }, - ctx - ); - self.step = LoginStep::PrivacySettings; - ctx.notify(); - } - LoginSlideAction::HideOverlay => { - // "Done" button in privacy settings returns to the auth pathway step, - // except when the user entered the slide via the terminal-intention theme slide's - // Privacy Settings link — in that case Back returns to the onboarding view. - self.active_overlay = None; - match self.source { - LoginSlideSource::PrivacySettingsFromTerminalIntentionTheme => { - ctx.emit(LoginSlideEvent::BackToOnboarding); - } - LoginSlideSource::OnboardingFlow - | LoginSlideSource::LoginExistingUserFromWelcome => { - self.step = LoginStep::SelectAuthPathway; - ctx.focus_self(); - ctx.notify(); - } - } - } - LoginSlideAction::ToggleTelemetry => { - let handle = PrivacySettings::handle(ctx); - ctx.update_model(&handle, |settings, ctx| { - settings.set_is_telemetry_enabled(!settings.is_telemetry_enabled, ctx); - }); - ctx.notify(); - } - LoginSlideAction::ToggleCrashReporting => { - let handle = PrivacySettings::handle(ctx); - ctx.update_model(&handle, |settings, ctx| { - settings - .set_is_crash_reporting_enabled(!settings.is_crash_reporting_enabled, ctx); - }); - ctx.notify(); - } - LoginSlideAction::ToggleCloudConversationStorage => { - let handle = PrivacySettings::handle(ctx); - ctx.update_model(&handle, |settings, ctx| { - settings.set_is_cloud_conversation_storage_enabled( - !settings.is_cloud_conversation_storage_enabled, - ctx, - ); - }); - ctx.notify(); - } - LoginSlideAction::DismissNotification => { - self.last_login_failure_reason = None; - ctx.notify(); - } - LoginSlideAction::PasteAuthUrl => { - self.last_login_failure_reason = None; - let clipboard_content = ctx.clipboard().read(); - if !clipboard_content.plain_text.is_empty() { - self.handle_pasted_auth_url(clipboard_content.plain_text, ctx); - } - } - } - } + type Action = (); + fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext) {} } diff --git a/app/src/auth/mod.rs b/app/src/auth/mod.rs index 3ca4c06c..7a465ea8 100644 --- a/app/src/auth/mod.rs +++ b/app/src/auth/mod.rs @@ -1,14 +1,9 @@ pub mod anonymous_id; pub mod auth_manager; -mod auth_override_warning_body; pub mod auth_override_warning_modal; pub mod auth_state; -mod auth_view_body; pub mod auth_view_modal; -mod auth_view_shared_helpers; pub mod credentials; -mod login_error_modal; -mod login_failure_notification; pub mod login_slide; pub mod needs_sso_link_view; pub mod paste_auth_token_modal; @@ -31,7 +26,7 @@ use ai::index::full_source_code_embedding::manager::CodebaseIndexManager; pub use auth_manager::AuthManager; pub use auth_state::AuthStateProvider; use itertools::Itertools; -pub use login_failure_notification::LoginFailureReason; +pub use auth_view_modal::LoginFailureReason; pub use user_uid::UserUid; use galaxyui::modals::{AlertDialogWithCallbacks, ModalButton}; @@ -59,13 +54,7 @@ use crate::{report_if_error, send_telemetry_sync_from_app_ctx}; #[cfg_attr(target_family = "wasm", allow(dead_code))] pub const API_KEY_PREFIX: &str = "wk-"; -pub fn init(app: &mut AppContext) { - auth_view_modal::init(app); - auth_view_body::init(app); - auth_override_warning_body::init(app); - login_slide::init(app); - paste_auth_token_modal::init(app); -} +pub fn init(_app: &mut AppContext) {} /// If the app has running processes or dirty objects, we'll show a confirmation modal before logging out. /// If the user aborts, the user will not be logged out. diff --git a/app/src/auth/needs_sso_link_view.rs b/app/src/auth/needs_sso_link_view.rs index b72fb367..885a9f73 100644 --- a/app/src/auth/needs_sso_link_view.rs +++ b/app/src/auth/needs_sso_link_view.rs @@ -1,36 +1,13 @@ -use super::auth_manager::AuthManager; -use crate::{appearance::Appearance, auth::login_error_modal::LoginErrorModal}; -use galaxyui::elements::{Align, MouseStateHandle, Shrinkable}; -use galaxyui::ui_components::button::ButtonVariant; -use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; -use galaxyui::{AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext}; +use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext}; -#[derive(Debug)] -pub enum NeedsSsoLinkViewAction { - ClickedLinkSsoButton, -} - -pub struct NeedsSsoLinkView { - email: Option, - mouse_state_handles: MouseStateHandles, -} - -#[derive(Default)] -struct MouseStateHandles { - link_sso_handle: MouseStateHandle, -} +pub struct NeedsSsoLinkView; impl NeedsSsoLinkView { pub fn new() -> Self { - Self { - email: None, - mouse_state_handles: Default::default(), - } + Self } - pub fn set_email(&mut self, email: String) { - self.email = Some(email); - } + pub fn set_email(&mut self, _email: String) {} } impl Entity for NeedsSsoLinkView { @@ -42,60 +19,12 @@ impl View for NeedsSsoLinkView { "NeedsSsoLinkView" } - fn render(&self, app: &AppContext) -> Box { - let appearance = Appearance::as_ref(app); - let ui_builder = appearance.ui_builder(); - - let link_sso_button = Shrinkable::new( - 1., - Align::new( - ui_builder - .button( - ButtonVariant::Accent, - self.mouse_state_handles.link_sso_handle.clone(), - ) - .with_text_label("Link SSO".to_string()) - .with_style(UiComponentStyles { - padding: Some(Coords { - top: 10., - bottom: 10., - left: 40., - right: 40., - }), - ..Default::default() - }) - .build() - .on_click(move |ctx, _, _| { - ctx.dispatch_typed_action(NeedsSsoLinkViewAction::ClickedLinkSsoButton); - }) - .finish(), - ) - .finish(), - ) - .finish(); - - LoginErrorModal::new(app) - .with_header("Your organization has enabled SSO for your account") - .with_detail("Click the button below to link your Warp account to your SSO provider.") - .with_action(link_sso_button) - .build() - .finish() + fn render(&self, _app: &AppContext) -> Box { + galaxyui::elements::Empty::new().finish() } } impl TypedActionView for NeedsSsoLinkView { - type Action = NeedsSsoLinkViewAction; - - fn handle_action(&mut self, action: &NeedsSsoLinkViewAction, ctx: &mut ViewContext) { - match action { - NeedsSsoLinkViewAction::ClickedLinkSsoButton => { - let email = self.email.as_deref().unwrap_or(""); - - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - let url = auth_manager.link_sso_url(email); - ctx.open_url(&url); - }); - } - } - } + type Action = (); + fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext) {} } diff --git a/app/src/auth/paste_auth_token_modal.rs b/app/src/auth/paste_auth_token_modal.rs index af5cbeca..3a16d455 100644 --- a/app/src/auth/paste_auth_token_modal.rs +++ b/app/src/auth/paste_auth_token_modal.rs @@ -1,210 +1,15 @@ -//! Modal shown when the user clicks "Click here to paste your token from -//! the browser" on the onboarding agent-slide upgrade-prompt bar. Accepts a -//! pasted auth redirect URL and routes it through -//! `AuthManager::initialize_user_from_auth_payload`. -//! -//! This lives in the app crate (not the onboarding crate) because it reuses -//! `EditorView` for the text input, which the onboarding crate doesn't -//! depend on. -use crate::appearance::Appearance; -use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; -use crate::auth::auth_view_modal::AuthRedirectPayload; -use crate::auth::login_failure_notification::LoginFailureReason; -use crate::editor::{ - EditorView, InteractionState, SingleLineEditorOptions, TextColors, TextOptions, -}; -use crate::server::server_api::auth::UserAuthenticationError; -use crate::themes::theme::Fill as ThemeFill; -use crate::util::bindings::CustomAction; - -use pathfinder_color::ColorU; -use ui_components::{button, Component as _, Options as _}; -use galaxy_core::ui::theme::color::internal_colors; -use galaxyui::elements::{ - Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Fill, - Flex, FormattedTextElement, HighlightedHyperlink, MainAxisAlignment, MainAxisSize, - MouseStateHandle, ParentElement, Radius, Shrinkable, Stack, -}; -use galaxyui::fonts::Weight; -use galaxyui::keymap::{FixedBinding, Keystroke}; -use galaxyui::text_layout::TextAlignment; -use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; -use galaxyui::{ - actions::StandardAction, AppContext, Element, Entity, FocusContext, SingletonEntity, - TypedActionView, View, ViewContext, ViewHandle, -}; - -const MODAL_WIDTH: f32 = 460.; -const AUTH_TOKEN_INPUT_BORDER_RADIUS: Radius = Radius::Pixels(4.); - -pub fn init(app: &mut AppContext) { - use galaxyui::keymap::macros::*; - app.register_fixed_bindings([ - FixedBinding::new( - "enter", - PasteAuthTokenModalAction::Confirm, - id!(PasteAuthTokenModalView::ui_name()), - ), - FixedBinding::new( - "escape", - PasteAuthTokenModalAction::Cancel, - id!(PasteAuthTokenModalView::ui_name()), - ), - FixedBinding::custom( - CustomAction::Paste, - PasteAuthTokenModalAction::PasteIntoEditor, - "Paste", - id!(PasteAuthTokenModalView::ui_name()), - ), - FixedBinding::standard( - StandardAction::Paste, - PasteAuthTokenModalAction::PasteIntoEditor, - id!(PasteAuthTokenModalView::ui_name()), - ), - ]); - - #[cfg(any(target_os = "linux", target_os = "windows"))] - app.register_fixed_bindings([FixedBinding::new( - "cmdorctrl-v", - PasteAuthTokenModalAction::PasteIntoEditor, - id!(PasteAuthTokenModalView::ui_name()), - )]); -} - -#[derive(Clone, Copy, Debug)] -pub enum PasteAuthTokenModalAction { - Confirm, - Cancel, - /// Cmd+V/Ctrl+V at the modal level — routes paste into the editor even - /// when focus is still on the modal itself rather than the input. - PasteIntoEditor, -} +use galaxyui::{AppContext, Element, Entity, TypedActionView, View, ViewContext}; #[derive(Clone, Debug)] pub enum PasteAuthTokenModalEvent { Cancelled, } -pub struct PasteAuthTokenModalView { - auth_token_input: ViewHandle, - cancel_button: button::Button, - continue_button: button::Button, - close_mouse_state: MouseStateHandle, - last_failure_reason: Option, - highlighted_hyperlink_state: HighlightedHyperlink, -} +pub struct PasteAuthTokenModalView; impl PasteAuthTokenModalView { - pub fn new(ctx: &mut ViewContext) -> Self { - let auth_token_input = ctx.add_typed_action_view(|ctx| { - let appearance = Appearance::as_ref(ctx); - let theme = appearance.theme(); - let bg_solid = theme.surface_2().into_solid(); - let default_color = ThemeFill::Solid(internal_colors::text_main(theme, bg_solid)); - let disabled_color = ThemeFill::Solid(internal_colors::text_disabled(theme, bg_solid)); - let hint_color = ThemeFill::Solid(internal_colors::text_sub(theme, bg_solid)); - let mut editor = EditorView::single_line( - SingleLineEditorOptions { - text: TextOptions { - font_size_override: Some(12.), - font_family_override: Some(appearance.ui_font_family()), - text_colors_override: Some(TextColors { - default_color, - disabled_color, - hint_color, - }), - ..Default::default() - }, - soft_wrap: false, - ..Default::default() - }, - ctx, - ); - editor.set_placeholder_text("Enter auth token", ctx); - editor - }); - - // When the editor sees an Enter/Paste/etc. commit, submit the current - // buffer text upward. This matches the semantics of the inline editor - // in `login_slide.rs`. - ctx.subscribe_to_view(&auth_token_input, |me, _, event, ctx| { - use crate::editor::Event::{AltEnter, CmdEnter, Enter, Paste, ShiftEnter}; - match event { - AltEnter | CmdEnter | Enter | Paste | ShiftEnter => { - me.submit(ctx); - } - _ => {} - }; - ctx.notify(); - }); - - // Handle AuthFailed for attempts that originated from this modal: show - // an inline error and re-enable the editor so the user can try again. - ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, _, event, ctx| { - if let AuthManagerEvent::AuthFailed(err) = event { - me.last_failure_reason = Some(match err { - UserAuthenticationError::InvalidStateParameter => { - LoginFailureReason::InvalidStateParameter - } - UserAuthenticationError::MissingStateParameter => { - LoginFailureReason::MissingStateParameter - } - UserAuthenticationError::DeniedAccessToken(_) - | UserAuthenticationError::UserAccountDisabled(_) - | UserAuthenticationError::Unexpected(_) => { - LoginFailureReason::FailedUserAuthentication - } - }); - me.set_editor_enabled(true, ctx); - ctx.notify(); - } - }); - - Self { - auth_token_input, - cancel_button: button::Button::default(), - continue_button: button::Button::default(), - close_mouse_state: MouseStateHandle::default(), - last_failure_reason: None, - highlighted_hyperlink_state: HighlightedHyperlink::default(), - } - } - - /// Disables the editor while the auth request is in flight. Re-enabled - /// automatically on `AuthManagerEvent::AuthFailed` or on local parse - /// failure in `submit`. - fn set_editor_enabled(&mut self, is_enabled: bool, ctx: &mut ViewContext) { - let state = if is_enabled { - InteractionState::Editable - } else { - InteractionState::Disabled - }; - self.auth_token_input - .update(ctx, |editor, ctx| editor.set_interaction_state(state, ctx)); - } - - fn submit(&mut self, ctx: &mut ViewContext) { - let text = self.auth_token_input.as_ref(ctx).buffer_text(ctx); - if text.trim().is_empty() { - return; - } - // Clear any previous error before the next attempt. - self.last_failure_reason = None; - self.set_editor_enabled(false, ctx); - match AuthRedirectPayload::from_raw_url(text) { - Ok(payload) => { - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.initialize_user_from_auth_payload(payload, true, ctx); - }); - } - Err(error) => { - log::error!("Failed to parse pasted auth URL: {error:#}"); - self.last_failure_reason = - Some(LoginFailureReason::InvalidRedirectUrl { was_pasted: true }); - self.set_editor_enabled(true, ctx); - ctx.notify(); - } - } + pub fn new(_ctx: &mut ViewContext) -> Self { + Self } } @@ -217,220 +22,12 @@ impl View for PasteAuthTokenModalView { "PasteAuthTokenModalView" } - fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { - if focus_ctx.is_self_focused() { - // Redirect focus to the editor so keystrokes immediately appear - // in the input field. - ctx.focus(&self.auth_token_input); - ctx.notify(); - } - } - - fn render(&self, app: &AppContext) -> Box { - let appearance = Appearance::as_ref(app); - let theme = appearance.theme(); - let dialog_surface = theme.surface_1(); - let dialog_surface_solid = dialog_surface.into_solid(); - let border_color = internal_colors::neutral_4(theme); - let input_bg = theme.surface_2(); - let input_bg_solid = input_bg.into_solid(); - let input_text_color: ColorU = internal_colors::text_main(theme, input_bg_solid); - let ui_builder = appearance.ui_builder(); - - let title = FormattedTextElement::from_str( - "Paste your auth token below", - appearance.ui_font_family(), - 16., - ) - .with_color(internal_colors::text_main(theme, dialog_surface_solid)) - .with_weight(Weight::Bold) - .with_line_height_ratio(1.25) - .finish(); - - let close_button = ui_builder - .close_button(24., self.close_mouse_state.clone()) - .build() - .on_click(|ctx: &mut galaxyui::EventContext, _, _| { - ctx.dispatch_typed_action(PasteAuthTokenModalAction::Cancel); - }) - .finish(); - - let title_row = Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) - .with_cross_axis_alignment(CrossAxisAlignment::Start) - .with_child(Shrinkable::new(1., title).finish()) - .with_child(close_button) - .finish(); - - let subtitle_color = internal_colors::text_sub(theme, dialog_surface_solid); - let subtitle = FormattedTextElement::from_str( - "Paste your auth token from the browser to get complete login.", - appearance.ui_font_family(), - 14., - ) - .with_color(subtitle_color) - .with_weight(Weight::Normal) - .with_alignment(TextAlignment::Left) - .with_line_height_ratio(1.2) - .finish(); - - let input = ui_builder - .text_input(self.auth_token_input.clone()) - .with_style(UiComponentStyles { - background: Some(input_bg.into()), - border_width: Some(1.), - border_color: Some(Fill::Solid(border_color)), - border_radius: Some(CornerRadius::with_all(AUTH_TOKEN_INPUT_BORDER_RADIUS)), - font_color: Some(input_text_color), - padding: Some(Coords { - top: 12., - bottom: 12., - left: 16., - right: 16., - }), - ..Default::default() - }) - .build() - .finish(); - - let mut body = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child( - Container::new(subtitle) - .with_margin_top(8.) - .with_margin_bottom(16.) - .finish(), - ) - .with_child(input); - - if let Some(reason) = &self.last_failure_reason { - let error_text = FormattedTextElement::new( - reason.to_formatted_text(), - 14., - appearance.ui_font_family(), - appearance.monospace_font_family(), - theme.ui_error_color(), - self.highlighted_hyperlink_state.clone(), - ) - .register_default_click_handlers(|url, _, ctx| { - ctx.open_url(&url.url); - }) - .finish(); - body = body.with_child(Container::new(error_text).with_margin_top(8.).finish()); - } - - let body = body.finish(); - - let cancel_button = self.cancel_button.render( - appearance, - button::Params { - content: button::Content::Label("Cancel".into()), - theme: &button::themes::Naked, - options: button::Options { - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(PasteAuthTokenModalAction::Cancel); - })), - ..button::Options::default(appearance) - }, - }, - ); - - let enter = Keystroke::parse("enter").unwrap_or_default(); - let continue_button = self.continue_button.render( - appearance, - button::Params { - content: button::Content::Label("Continue".into()), - theme: &button::themes::Primary, - options: button::Options { - keystroke: Some(enter), - on_click: Some(Box::new(|ctx, _app, _pos| { - ctx.dispatch_typed_action(PasteAuthTokenModalAction::Confirm); - })), - ..button::Options::default(appearance) - }, - }, - ); - - let footer = Container::new( - Flex::row() - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::End) - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child(cancel_button) - .with_child( - Container::new(continue_button) - .with_margin_left(8.) - .finish(), - ) - .finish(), - ) - .with_border(Border::top(1.).with_border_color(border_color)) - .with_horizontal_padding(24.) - .with_vertical_padding(12.) - .finish(); - - let dialog = Flex::column() - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child( - Container::new(title_row) - .with_horizontal_padding(24.) - .with_padding_top(24.) - .with_padding_bottom(12.) - .finish(), - ) - .with_child( - Container::new(body) - .with_horizontal_padding(24.) - .with_padding_bottom(16.) - .finish(), - ) - .with_child(footer) - .finish(); - - let modal = ConstrainedBox::new( - Container::new(dialog) - .with_background(dialog_surface) - .with_border(Border::all(1.).with_border_color(border_color)) - .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) - .finish(), - ) - .with_width(MODAL_WIDTH) - .finish(); - - // Dim backdrop with click-to-dismiss behavior (matches the mockup). - let mut stack = Stack::new(); - stack.add_child( - Container::new(galaxyui::elements::Empty::new().finish()) - .with_background_color(ColorU::new(0, 0, 0, 179)) - .finish(), - ); - stack.add_child( - Dismiss::new(Align::new(modal).finish()) - .on_dismiss(|ctx, _app| { - ctx.dispatch_typed_action(PasteAuthTokenModalAction::Cancel); - }) - .finish(), - ); - stack.finish() + fn render(&self, _app: &AppContext) -> Box { + galaxyui::elements::Empty::new().finish() } } impl TypedActionView for PasteAuthTokenModalView { - type Action = PasteAuthTokenModalAction; - - fn handle_action(&mut self, action: &PasteAuthTokenModalAction, ctx: &mut ViewContext) { - match action { - PasteAuthTokenModalAction::Confirm => { - self.submit(ctx); - } - PasteAuthTokenModalAction::Cancel => { - ctx.emit(PasteAuthTokenModalEvent::Cancelled); - } - PasteAuthTokenModalAction::PasteIntoEditor => { - self.auth_token_input - .update(ctx, |editor, ctx| editor.paste(ctx)); - } - } - } + type Action = (); + fn handle_action(&mut self, _action: &(), _ctx: &mut ViewContext) {} } diff --git a/app/src/auth/web_handoff.rs b/app/src/auth/web_handoff.rs index fce72d63..03e932c2 100644 --- a/app/src/auth/web_handoff.rs +++ b/app/src/auth/web_handoff.rs @@ -1,117 +1,18 @@ -use anyhow::anyhow; -use wasm_bindgen::prelude::*; +use galaxyui::{AppContext, Element, Entity, View, ViewContext}; -use galaxyui::{ - ui_components::components::UiComponent as _, AppContext, Element, Entity, SingletonEntity, - View, ViewContext, -}; - -use crate::{ - auth::auth_view_modal::AuthRedirectPayload, - auth::credentials::RefreshToken, - auth::login_error_modal::LoginErrorModal, - platform::wasm::{user_handoff, AuthHandoffError}, - report_error, -}; - -use super::auth_manager::{AuthManager, AuthManagerEvent}; - -#[wasm_bindgen] -extern "C" {} - -pub struct WebHandoffView { - state: HandoffState, -} - -#[derive(Debug, Clone)] +#[derive(Clone, Debug)] pub enum WebHandoffEvent { - /// Web auth handoff is unavailable, so the app should fall back to the login screen. Unsupported, } -enum HandoffState { - /// We have retrieved a refresh token from the host application and are fetching the user's - /// profile. - LoadingFromHost, - /// We are deriving authentication from an ambient browser session cookie. - LoadingFromSessionCookie, - /// There was an error using the provided refresh token. In practice, this should never happen, - /// as the host application would have recently used the token successfully. - Failed, -} +pub struct WebHandoffView; impl WebHandoffView { - pub fn new(ctx: &mut ViewContext) -> Self { - ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, _, event, ctx| { - me.handle_auth_manager_event(event, ctx); - }); - - Self { - state: HandoffState::Failed, - } + pub fn new(_ctx: &mut ViewContext) -> Self { + Self } - fn import_user_from_session_cookie(&mut self, ctx: &mut ViewContext) { - log::debug!("Attempting to derive auth from browser session cookie"); - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.initialize_user_from_session_cookie(ctx); - }); - self.state = HandoffState::LoadingFromSessionCookie; - } - - /// Import the authenticated user from the host React app, if available. - pub fn import_user(&mut self, ctx: &mut ViewContext) { - match user_handoff() { - Ok(Some(refresh_token)) => { - log::debug!("Attempting to retrieve refresh token from host app"); - let payload = AuthRedirectPayload { - refresh_token: RefreshToken::new(refresh_token), - user_uid: None, - deleted_anonymous_user: None, - state: None, - }; - - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - // No need to validate state for web handoff, since everything's happening - // on same web page. - auth_manager.initialize_user_from_auth_payload(payload, false, ctx); - }); - self.state = HandoffState::LoadingFromHost; - } - Ok(None) => { - self.import_user_from_session_cookie(ctx); - } - Err(AuthHandoffError::Unsupported) => { - self.import_user_from_session_cookie(ctx); - } - Err(AuthHandoffError::Unexpected(err)) => { - report_error!(anyhow!("Web user handoff failed: {err:?}")); - self.state = HandoffState::Failed; - ctx.notify(); - } - } - ctx.notify(); - } - - fn handle_auth_manager_event(&mut self, event: &AuthManagerEvent, ctx: &mut ViewContext) { - match event { - AuthManagerEvent::AuthComplete => { - log::debug!("Initialized user from host application"); - } - AuthManagerEvent::AuthFailed(err) => { - if matches!(self.state, HandoffState::LoadingFromSessionCookie) { - log::debug!("No browser session available for web auth handoff: {err:#}"); - ctx.emit(WebHandoffEvent::Unsupported); - return; - } - - log::error!("Failed to import user from host application: {err:#}"); - self.state = HandoffState::Failed; - ctx.notify(); - } - _ => {} - } - } + pub fn import_user(&mut self, _ctx: &mut ViewContext) {} } impl Entity for WebHandoffView { @@ -123,15 +24,7 @@ impl View for WebHandoffView { "WebHandoffView" } - fn render(&self, app: &AppContext) -> Box { - let label = match &self.state { - HandoffState::LoadingFromHost | HandoffState::LoadingFromSessionCookie => "Loading...", - HandoffState::Failed => "Error authenticating - please refresh the page", - }; - - LoginErrorModal::new(app) - .with_detail(label) - .build() - .finish() + fn render(&self, _app: &AppContext) -> Box { + galaxyui::elements::Empty::new().finish() } } diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index 6b3935b5..a1d225ee 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -1323,12 +1323,6 @@ impl ServerApiProvider { ctx.dispatch_global_action("app:log_out", ()); } ServerApiEvent::NeedsReauth => { - // AuthManager depends on a reference to ServerApi, so ServerApi can't easily - // hold a ref to AuthManager. To get around this, we emit an event on ServerApi - // and handle calling the AuthManager here instead. - AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { - auth_manager.set_needs_reauth(true, ctx); - }); } // Re-emit the event for subscribers. // TODO: we probably want a different type for the event emitted to subscribers diff --git a/app/src/server/server_api/auth.rs b/app/src/server/server_api/auth.rs index b60d69a8..6b85f591 100644 --- a/app/src/server/server_api/auth.rs +++ b/app/src/server/server_api/auth.rs @@ -1,99 +1,45 @@ -use std::{result::Result as StdResult, sync::Arc}; +use std::result::Result as StdResult; -use anyhow::{anyhow, bail, Context as _, Result}; +use anyhow::{anyhow, bail, Result}; use async_trait::async_trait; -use cynic::{MutationBuilder, QueryBuilder}; -use firebase::{FetchAccessTokenResponse, FirebaseError}; -use futures::FutureExt; +use firebase::FirebaseError; use instant::Duration; #[cfg(test)] use mockall::{automock, predicate::*}; -use oauth2::TokenResponse; use thiserror::Error; use galaxy_core::errors::{AnyhowErrorExt, ErrorExt}; -use galaxy_graphql::client::Operation; -use galaxy_graphql::mutations::expire_api_key::{ - ExpireApiKey, ExpireApiKeyResult, ExpireApiKeyVariables, -}; -use galaxy_graphql::queries::get_conversation_usage::{ - ConversationUsage, GetConversationUsage, GetConversationUsageVariables, UserResult, -}; +use galaxy_graphql::mutations::expire_api_key::ExpireApiKeyResult; +use galaxy_graphql::queries::get_conversation_usage::ConversationUsage; -use galaxy_graphql::mutations::set_user_is_onboarded::{ - SetUserIsOnboarded, SetUserIsOnboardedResult, SetUserIsOnboardedVariables, -}; -use galaxy_graphql::mutations::update_user_settings::{ - UpdateUserSettings, UpdateUserSettingsInput, UpdateUserSettingsResult, - UpdateUserSettingsVariables, -}; -use galaxy_graphql::mutations::{ - create_anonymous_user::{ - AnonymousUserType, CreateAnonymousUser, CreateAnonymousUserResult, - CreateAnonymousUserVariables, - }, - generate_api_key::{ - GenerateApiKey, GenerateApiKeyInput, GenerateApiKeyResult, GenerateApiKeyVariables, - }, - mint_custom_token::{MintCustomTokenResult, MintCustomTokenVariables}, +use galaxy_graphql::mutations::create_anonymous_user::{ + AnonymousUserType, CreateAnonymousUserResult, }; +use galaxy_graphql::mutations::generate_api_key::GenerateApiKeyResult; +use galaxy_graphql::mutations::mint_custom_token::MintCustomTokenResult; use galaxy_graphql::object_permissions::OwnerType; -use galaxy_graphql::queries::api_keys::{ - ApiKeyProperties, ApiKeyPropertiesResult, ApiKeys, ApiKeysVariables, -}; -use galaxy_graphql::queries::get_user::{GetUser, GetUserVariables, UserOutput as GqlUserOutput}; -use galaxy_graphql::queries::get_user_settings::{GetUserSettings, GetUserSettingsVariables}; -use galaxyui::r#async::BoxFuture; +use galaxy_graphql::queries::api_keys::ApiKeyProperties; +use galaxy_graphql::queries::get_user::UserOutput as GqlUserOutput; use crate::auth::UserUid; -use crate::server::graphql::{default_request_options, get_user_facing_error_message}; use crate::server::ids::ApiKeyUid; use crate::server::server_api::register_error; -use crate::server::server_api::EXPERIMENT_ID_HEADER; use crate::settings::PrivacySettingsSnapshot; use crate::{ auth::{ - credentials::{AuthToken, Credentials, FirebaseToken, LoginToken, RefreshToken}, - user::FirebaseAuthTokens, + credentials::{AuthToken, Credentials, FirebaseToken, LoginToken}, user::User, }, - channel::ChannelState, - convert_to_server_experiment, - server::{ - datetime_ext::DateTimeExt as _, experiments::ServerExperiment, - graphql::get_request_context, server_api::ServerApiEvent, - }, + server::experiments::ServerExperiment, }; use super::ServerApi; -/// Error messages returned from the Firebase REST API when attempting to convert a refresh token -/// into an access token that indicate the user's token is in an errored state. -/// These are "soft" errors because the user likely just needs to log in again. -/// See https://firebase.google.com/docs/reference/rest/auth#section-refresh-token. -static FETCH_ACCESS_TOKEN_SOFT_ERROR_MESSAGES: &[&str] = &[ - "TOKEN_EXPIRED", - "INVALID_REFRESH_TOKEN", - "MISSING_REFRESH_TOKEN", -]; - -/// Error messages returned from the Firebase REST API when attempting to convert a refresh token -/// into an access token that indicate the user's account is in an errored state. -/// These are "hard" errors because the user likely can no longer sign in with their account, -/// for example if it were disabled or deleted. -/// See https://firebase.google.com/docs/reference/rest/auth#section-refresh-token. -static FETCH_ACCESS_TOKEN_HARD_ERROR_MESSAGES: &[&str] = &["USER_DISABLED", "USER_NOT_FOUND"]; - -const FETCH_ACCESS_TOKEN_TIMEOUT: Duration = Duration::from_secs(5); - /// Header key for the ambient workload token attached to multi-agent requests. pub const AMBIENT_WORKLOAD_TOKEN_HEADER: &str = "X-Warp-Ambient-Workload-Token"; /// Header key for the cloud agent task ID attached to requests from ambient agents. pub const CLOUD_AGENT_ID_HEADER: &str = "X-Warp-Cloud-Agent-ID"; -/// Duration for which the ambient workload token is valid (3 hours). -const AMBIENT_WORKLOAD_TOKEN_DURATION: Duration = Duration::from_secs(3 * 60 * 60); - /// User settings that are currently 'synced' (e.g. stored server-side) on a per-user basis. #[derive(Copy, Clone, Debug, Default)] pub struct SyncedUserSettings { @@ -217,459 +163,108 @@ pub trait AuthClient: 'static + Send + Sync { impl AuthClient for ServerApi { async fn create_anonymous_user( &self, - referral_code: Option, - anonymous_user_type: AnonymousUserType, + _referral_code: Option, + _anonymous_user_type: AnonymousUserType, ) -> Result { - let variables = CreateAnonymousUserVariables { - input: galaxy_graphql::mutations::create_anonymous_user::CreateAnonymousUserInput { - anonymous_user_type, - expiration_type: galaxy_graphql::mutations::create_anonymous_user::AnonymousUserExpirationType::NoExpiration, - referral_code, - }, - request_context: get_request_context(), - }; - - let operation = CreateAnonymousUser::build(variables); - let response = operation - .send_request(self.client.clone(), default_request_options()) - .await?; - - Ok(response - .data - .ok_or_else(|| anyhow!("missing data in response"))? - .create_anonymous_user) + bail!("Server auth disabled") } async fn get_or_refresh_access_token(&self) -> Result { - if cfg!(feature = "skip_login") { - bail!("skip_login enabled; failing all authenticated requests"); - } - - let Some(credentials) = self.auth_state.credentials() else { - bail!("Attempted to retrieve access token when user is logged out"); - }; - - match credentials { - Credentials::ApiKey { key, .. } => Ok(AuthToken::ApiKey(key)), - Credentials::Firebase(auth_tokens) => { - let expiration_time = auth_tokens.expiration_time; - - // Generate a new ID token if the token has expired or will expire in the - // next five minutes. This matches the behavior of the Firebase Auth SDK. - if chrono::DateTime::now() + chrono::Duration::minutes(5) >= expiration_time { - let refresh_token = auth_tokens.refresh_token.clone(); - let firebase_token = FirebaseToken::Refresh(RefreshToken::new(refresh_token)); - - let result = fetch_auth_tokens(self.client.clone(), firebase_token).await; - - if let Err(UserAuthenticationError::DeniedAccessToken(_)) = result { - let _ = self.event_sender.send(ServerApiEvent::NeedsReauth).await; - } - let new_firebase_token_info = result?; - self.auth_state - .update_firebase_tokens(new_firebase_token_info.clone()); - let _ = self - .event_sender - .send(ServerApiEvent::AccessTokenRefreshed { - token: new_firebase_token_info.id_token.clone(), - }) - .await; - return Ok(AuthToken::Firebase(new_firebase_token_info.id_token)); - } - - Ok(AuthToken::Firebase(auth_tokens.id_token)) - } - Credentials::SessionCookie => Ok(AuthToken::NoAuth), - #[cfg(any(test, feature = "integration_tests", feature = "skip_login"))] - Credentials::Test => Ok(AuthToken::NoAuth), - } + Ok(AuthToken::NoAuth) } async fn fetch_user( &self, - token: LoginToken, - for_refresh: bool, + _token: LoginToken, + _for_refresh: bool, ) -> StdResult { - let new_credentials = exchange_credentials(self.client.clone(), token).await?; - let auth_token = new_credentials.bearer_token(); - let user_output = self - .fetch_user_properties(auth_token.as_bearer_token()) - .await - .context("Failed to fetch user response data") - .map_err(UserAuthenticationError::Unexpected)?; - - let UserProperties { - user, - server_experiments, - llms, - api_key_owner_type, - } = user_output.into(); - - // Store the owner type if using an API key. - let new_credentials = match new_credentials { - Credentials::ApiKey { key, .. } => Credentials::ApiKey { - key, - owner_type: api_key_owner_type, - }, - other => other, - }; - - Ok(FetchUserResult { - user, - credentials: new_credentials, - server_experiments, - from_refresh: for_refresh, - llms, - }) + Err(UserAuthenticationError::Unexpected(anyhow!("Server auth disabled"))) } async fn fetch_new_custom_token(&self) -> Result { - let variables = MintCustomTokenVariables { - request_context: get_request_context(), - }; - - let operation = - galaxy_graphql::mutations::mint_custom_token::MintCustomToken::build(variables); - let response = self.send_graphql_request(operation, None).await?; - Ok(response.mint_custom_token) + bail!("Server auth disabled") } fn on_custom_token_fetched( &self, - response: Result, + _response: Result, ) -> Result { - match response { - Ok(response_data) => match response_data { - MintCustomTokenResult::MintCustomTokenOutput(output) => Ok(output.custom_token), - MintCustomTokenResult::UserFacingError(user_facing_error) => { - Err(MintCustomTokenError::UserFacingError( - get_user_facing_error_message(user_facing_error), - )) - } - MintCustomTokenResult::Unknown => Err(MintCustomTokenError::Unknown), - }, - Err(_) => Err(MintCustomTokenError::Unknown), - } + Err(MintCustomTokenError::Unknown) } async fn fetch_user_properties<'a>( &self, - auth_token: Option<&'a str>, + _auth_token: Option<&'a str>, ) -> Result { - let variables = GetUserVariables { - request_context: get_request_context(), - }; - let operation = GetUser::build(variables); - let response = operation - .send_request( - self.client.clone(), - galaxy_graphql::client::RequestOptions { - auth_token: auth_token.map(ToOwned::to_owned), - headers: std::collections::HashMap::from([( - EXPERIMENT_ID_HEADER.to_string(), - self.auth_state.anonymous_id(), - )]), - ..default_request_options() - }, - ) - .await? - .data - .ok_or_else(|| anyhow!("Expected valid response.data"))?; - - match response.user { - galaxy_graphql::queries::get_user::UserResult::UserOutput(user_output) => Ok(user_output), - galaxy_graphql::queries::get_user::UserResult::Unknown => { - Err(anyhow!("Unable to fetch user")) - } - } + bail!("Server auth disabled") } async fn get_user_settings(&self) -> Result> { - let variables = GetUserSettingsVariables { - request_context: get_request_context(), - }; - let operation = GetUserSettings::build(variables); - let response = self.send_graphql_request(operation, None).await?; - - match response.user { - galaxy_graphql::queries::get_user_settings::UserResult::UserOutput(user_output) => { - match user_output.user.settings { - Some(user_settings) => Ok(Some(SyncedUserSettings { - is_cloud_conversation_storage_enabled: user_settings - .is_cloud_conversation_storage_enabled, - is_crash_reporting_enabled: user_settings.is_crash_reporting_enabled, - is_telemetry_enabled: user_settings.is_telemetry_enabled, - })), - None => Ok(None), - } - } - galaxy_graphql::queries::get_user_settings::UserResult::Unknown => { - Err(anyhow!("Unable to fetch user settings")) - } - } + Ok(None) } - // Returns a history of the current user's conversation usage over the past n days. async fn get_conversation_usage_history( &self, - days: Option, - limit: Option, - last_updated_end_timestamp: Option, + _days: Option, + _limit: Option, + _last_updated_end_timestamp: Option, ) -> Result> { - let operation = GetConversationUsage::build(GetConversationUsageVariables { - request_context: get_request_context(), - days, - limit, - last_updated_end_timestamp, - }); - let response = self.send_graphql_request(operation, None).await?; - match response.user { - UserResult::UserOutput(out) => Ok(out.user.conversation_usage), - UserResult::Unknown => Err(anyhow!("Unable to fetch conversation usage")), - } + Ok(vec![]) } - async fn set_is_telemetry_enabled(&self, value: bool) -> Result<()> { - let variables = UpdateUserSettingsVariables { - input: UpdateUserSettingsInput { - telemetry_enabled: Some(value), - ..Default::default() - }, - request_context: get_request_context(), - }; - - let operation = UpdateUserSettings::build(variables); - let result = self - .send_graphql_request(operation, None) - .await? - .update_user_settings; - - match result { - UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()), - UpdateUserSettingsResult::UserFacingError(user_facing_error) => { - Err(anyhow!(get_user_facing_error_message(user_facing_error))) - } - UpdateUserSettingsResult::Unknown => Err(anyhow!("failed to set telemetry enabled")), - } + async fn set_is_telemetry_enabled(&self, _value: bool) -> Result<()> { + Ok(()) } - async fn set_is_crash_reporting_enabled(&self, value: bool) -> Result<()> { - let variables = UpdateUserSettingsVariables { - input: UpdateUserSettingsInput { - crash_reporting_enabled: Some(value), - ..Default::default() - }, - request_context: get_request_context(), - }; - - let operation = UpdateUserSettings::build(variables); - let result = self - .send_graphql_request(operation, None) - .await? - .update_user_settings; - - match result { - UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()), - UpdateUserSettingsResult::UserFacingError(user_facing_error) => { - Err(anyhow!(get_user_facing_error_message(user_facing_error))) - } - UpdateUserSettingsResult::Unknown => { - Err(anyhow!("failed to set crash reporting enabled")) - } - } + async fn set_is_crash_reporting_enabled(&self, _value: bool) -> Result<()> { + Ok(()) } - async fn set_is_cloud_conversation_storage_enabled(&self, value: bool) -> Result<()> { - let variables = UpdateUserSettingsVariables { - input: UpdateUserSettingsInput { - cloud_conversation_storage_enabled: Some(value), - ..Default::default() - }, - request_context: get_request_context(), - }; - - let operation = UpdateUserSettings::build(variables); - let result = self - .send_graphql_request(operation, None) - .await? - .update_user_settings; - - match result { - UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()), - UpdateUserSettingsResult::UserFacingError(user_facing_error) => { - Err(anyhow!(get_user_facing_error_message(user_facing_error))) - } - UpdateUserSettingsResult::Unknown => { - Err(anyhow!("failed to set cloud conversation storage enabled")) - } - } + async fn set_is_cloud_conversation_storage_enabled(&self, _value: bool) -> Result<()> { + Ok(()) } - async fn update_user_settings(&self, settings_snapshot: PrivacySettingsSnapshot) -> Result<()> { - let variables = UpdateUserSettingsVariables { - input: UpdateUserSettingsInput { - telemetry_enabled: Some(settings_snapshot.is_telemetry_enabled()), - crash_reporting_enabled: Some(settings_snapshot.is_crash_reporting_enabled()), - cloud_conversation_storage_enabled: settings_snapshot - .cloud_conversation_storage_enabled(), - }, - request_context: get_request_context(), - }; - - let operation = UpdateUserSettings::build(variables); - let result = self - .send_graphql_request(operation, None) - .await? - .update_user_settings; - - match result { - UpdateUserSettingsResult::UpdateUserSettingsOutput(_) => Ok(()), - UpdateUserSettingsResult::UserFacingError(user_facing_error) => { - Err(anyhow!(get_user_facing_error_message(user_facing_error))) - } - UpdateUserSettingsResult::Unknown => Err(anyhow!("failed to update user settings")), - } + async fn update_user_settings(&self, _settings_snapshot: PrivacySettingsSnapshot) -> Result<()> { + Ok(()) } async fn set_user_is_onboarded(&self) -> Result { - let variables = SetUserIsOnboardedVariables { - request_context: get_request_context(), - }; - - let operation = SetUserIsOnboarded::build(variables); - let result = self - .send_graphql_request(operation, None) - .await? - .set_user_is_onboarded; - - match result { - SetUserIsOnboardedResult::SetUserIsOnboardedOutput(_) => Ok(true), - SetUserIsOnboardedResult::UserFacingError(user_facing_error) => { - Err(anyhow!(get_user_facing_error_message(user_facing_error))) - } - SetUserIsOnboardedResult::Unknown => Err(anyhow!("failed to set user is onboarded")), - } + Ok(true) } async fn request_device_code( &self, ) -> StdResult { - self.oauth_client - .exchange_device_code() - .request_async(self.client.as_ref()) - .await - .context("Failed to generate device code") - .map_err(UserAuthenticationError::Unexpected) + Err(UserAuthenticationError::Unexpected(anyhow!("Server auth disabled"))) } async fn exchange_device_access_token( &self, - details: &oauth2::StandardDeviceAuthorizationResponse, - timeout: Duration, + _details: &oauth2::StandardDeviceAuthorizationResponse, + _timeout: Duration, ) -> StdResult { - let result = self - .oauth_client - .exchange_device_access_token(details) - .request_async( - self.client.as_ref(), - |delay| galaxyui::r#async::Timer::after(delay).map(|_| ()), - Some(timeout), - ) - .await - .context("Unable to obtain access token") - .map_err(UserAuthenticationError::Unexpected)?; - - // Firebase doesn't directly support the device flow. Instead, the server mints a short-lived - // custom access token, which we can then exchange for a refresh token. - Ok(FirebaseToken::Custom( - result.access_token().secret().to_string(), - )) + Err(UserAuthenticationError::Unexpected(anyhow!("Server auth disabled"))) } - // API Keys async fn list_api_keys(&self) -> Result> { - let variables = ApiKeysVariables { - request_context: get_request_context(), - }; - let operation = ApiKeys::build(variables); - let response = self.send_graphql_request(operation, None).await?; - match response.api_keys { - ApiKeyPropertiesResult::ApiKeyPropertiesOutput(output) => Ok(output.api_keys), - ApiKeyPropertiesResult::UserFacingError(e) => { - Err(anyhow!(get_user_facing_error_message(e))) - } - ApiKeyPropertiesResult::Unknown => Err(anyhow!("failed to fetch API keys")), - } + Ok(vec![]) } async fn create_api_key( &self, - name: String, - team_id: Option, - expires_at: Option, + _name: String, + _team_id: Option, + _expires_at: Option, ) -> Result { - let variables = GenerateApiKeyVariables { - input: GenerateApiKeyInput { - name, - team_id, - expires_at, - }, - request_context: get_request_context(), - }; - let operation = GenerateApiKey::build(variables); - let response = self.send_graphql_request(operation, None).await?; - Ok(response.generate_api_key) + bail!("Server auth disabled") } - async fn expire_api_key(&self, key_uid: &ApiKeyUid) -> Result { - let variables = ExpireApiKeyVariables { - key_uid: key_uid.into(), - request_context: get_request_context(), - }; - let op = ExpireApiKey::build(variables); - let res = self.send_graphql_request(op, None).await?; - Ok(res.expire_api_key) + + async fn expire_api_key(&self, _key_uid: &ApiKeyUid) -> Result { + bail!("Server auth disabled") } async fn get_or_create_ambient_workload_token(&self) -> Result> { - if cfg!(target_family = "wasm") { - return Ok(None); - } - - // Check if we have a cached token that's still valid (with 5 minute buffer). - // Tokens without an expiration time are always considered valid. - { - let cached = self.ambient_workload_token.lock(); - if let Some(ref token) = *cached { - let is_valid = token.expires_at.is_none_or(|expires_at| { - chrono::Utc::now() + chrono::Duration::minutes(5) < expires_at - }); - if is_valid { - return Ok(Some(token.token.clone())); - } - } - } - - // Issue a new token. - let workload_token = match galaxy_isolation_platform::issue_workload_token(Some( - AMBIENT_WORKLOAD_TOKEN_DURATION, - )) - .await - { - Ok(token) => token, - Err(galaxy_isolation_platform::IsolationPlatformError::NoIsolationPlatformDetected) => { - return Ok(None); - } - Err(e) => return Err(e.into()), - }; - - let token_str = workload_token.token.clone(); - - { - let mut cached = self.ambient_workload_token.lock(); - *cached = Some(workload_token); - } - - Ok(Some(token_str)) + Ok(None) } } diff --git a/app/src/settings_view/mod.rs b/app/src/settings_view/mod.rs index 57a11431..52c82c79 100644 --- a/app/src/settings_view/mod.rs +++ b/app/src/settings_view/mod.rs @@ -187,10 +187,10 @@ pub enum SettingsViewEvent { #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] pub enum SettingsSection { About, - #[default] Account, MCPServers, BillingAndUsage, + #[default] Appearance, Features, Keybindings, @@ -1188,12 +1188,10 @@ impl SettingsView { // Build sidebar nav items. AI page is presented as an "Agents" umbrella // with subpages; the actual AI SettingsPage is hidden from direct sidebar listing. let mut nav_items = vec![ - SettingsNavItem::Page(SettingsSection::Account), SettingsNavItem::Umbrella(SettingsUmbrella::new( "Agents", SettingsSection::ai_subpages().to_vec(), )), - SettingsNavItem::Page(SettingsSection::BillingAndUsage), SettingsNavItem::Umbrella(SettingsUmbrella::new( "Code", vec![ diff --git a/script/mirror-repos-to-gitlab.sh b/script/mirror-repos-to-gitlab.sh new file mode 100755 index 00000000..3e7abc1b --- /dev/null +++ b/script/mirror-repos-to-gitlab.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +NAMESPACE_ID=12784174 +GROUP_PATH="samnasbo/shared" +VISIBILITY="private" +TMP_DIR=$(mktemp -d) +RESULTS_FILE="$TMP_DIR/results.txt" + +echo "Temp directory: $TMP_DIR" + +REPOS=( + "command-corrections:warpdotdev/command-corrections" + "font-kit:warpdotdev/font-kit" + "mermaid-to-svg:warpdotdev/mermaid-to-svg" + "notify:warpdotdev/notify" + "session-sharing-protocol:warpdotdev/session-sharing-protocol" + "vte:warpdotdev/vte" + "workflows:warpdotdev/workflows" + "warp-proto-apis:warpdotdev/warp-proto-apis" + "command-signatures:warpdotdev/command-signatures" + "winit:warpdotdev/winit" + "rmcp:warpdotdev/rmcp" + "rust-objc:warpdotdev/rust-objc" + "pathfinder:warpdotdev/pathfinder" + "yaml-rust:warpdotdev/yaml-rust" + "tink-rust:warpdotdev/tink-rust" + "jemallocator:warpdotdev/jemallocator" +) + +for entry in "${REPOS[@]}"; do + NAME="${entry%%:*}" + GITHUB_PATH="${entry##*:}" + GITHUB_URL="git@github.com:${GITHUB_PATH}.git" + + echo "" + echo "=========================================" + echo "Processing: $NAME" + echo "=========================================" + + # 1) Check if GitLab repo already exists + ENCODED_PATH=$(echo "${GROUP_PATH}/${NAME}" | sed 's/\//%2F/g') + EXISTING=$(glab api "projects/$ENCODED_PATH" 2>&1 | cat) + EXISTING_SSH=$(echo "$EXISTING" | jq -r '.ssh_url_to_repo' 2>/dev/null) + + if [ -n "$EXISTING_SSH" ] && [ "$EXISTING_SSH" != "null" ]; then + echo "Repo already exists on GitLab: $EXISTING_SSH" + SSH_URL="$EXISTING_SSH" + FULL_PATH=$(echo "$EXISTING" | jq -r '.path_with_namespace') + else + # Create empty repo in GitLab + echo "Creating GitLab repo..." + CREATE_RESPONSE=$(glab api --method POST projects \ + -f "name=$NAME" \ + -f "namespace_id=$NAMESPACE_ID" \ + -f "visibility=$VISIBILITY" | cat) + + SSH_URL=$(echo "$CREATE_RESPONSE" | jq -r '.ssh_url_to_repo') + FULL_PATH=$(echo "$CREATE_RESPONSE" | jq -r '.path_with_namespace') + + if [ "$SSH_URL" = "null" ] || [ -z "$SSH_URL" ]; then + echo "ERROR: Failed to create repo for $NAME" + echo "$CREATE_RESPONSE" | jq . + echo "$NAME | FAILED | n/a | n/a" >> "$RESULTS_FILE" + continue + fi + fi + + echo "GitLab SSH URL: $SSH_URL" + + # 2) Clone from GitHub into tmp folder (skip if already cloned) + if [ ! -d "$TMP_DIR/$NAME" ]; then + echo "Cloning from GitHub..." + if ! git clone "$GITHUB_URL" "$TMP_DIR/$NAME"; then + echo "ERROR: Failed to clone $NAME from GitHub" + echo "$NAME | $FULL_PATH | CLONE_FAILED | n/a" >> "$RESULTS_FILE" + continue + fi + else + echo "Already cloned locally, skipping clone." + fi + + # 3) Update origin to GitLab SSH URL + git -C "$TMP_DIR/$NAME" remote set-url origin "$SSH_URL" + + # 4) Push default branch to GitLab + DEFAULT_BRANCH=$(git -C "$TMP_DIR/$NAME" symbolic-ref --short HEAD) + echo "Pushing branch '$DEFAULT_BRANCH' to GitLab..." + if ! git -C "$TMP_DIR/$NAME" push -u origin "$DEFAULT_BRANCH"; then + echo "ERROR: Failed to push $NAME to GitLab" + echo "$NAME | $FULL_PATH | PUSH_FAILED | n/a" >> "$RESULTS_FILE" + continue + fi + + # Record result + COMMIT=$(git -C "$TMP_DIR/$NAME" rev-parse HEAD) + echo "$NAME | $FULL_PATH | $DEFAULT_BRANCH | $COMMIT" >> "$RESULTS_FILE" + + echo "Done: $NAME" +done + +# 5) Print summary +echo "" +echo "=========================================" +echo "RESULTS" +echo "=========================================" +printf "%-30s %-50s %-15s %s\n" "REPO" "GITLAB PATH" "BRANCH" "COMMIT" +echo "---------------------------------------------------------------------------------------------------------" +while IFS='|' read -r name path branch commit; do + printf "%-30s %-50s %-15s %s\n" "$(echo "$name" | xargs)" "$(echo "$path" | xargs)" "$(echo "$branch" | xargs)" "$(echo "$commit" | xargs)" +done < "$RESULTS_FILE" + +echo "" +echo "Temp directory (can be removed): $TMP_DIR"