steer_tui/tui/widgets/setup/
authentication.rs

1use crate::tui::state::SetupState;
2use crate::tui::theme::{Component, Theme};
3use ratatui::{
4    buffer::Buffer,
5    layout::{Alignment, Constraint, Direction, Layout, Rect},
6    text::{Line, Span},
7    widgets::{Block, Borders, Paragraph, Widget, Wrap},
8};
9use steer_core::config::provider::{self, ProviderId};
10
11pub struct AuthenticationWidget;
12
13impl AuthenticationWidget {
14    pub fn render(
15        area: Rect,
16        buf: &mut Buffer,
17        state: &SetupState,
18        provider_id: ProviderId,
19        theme: &Theme,
20    ) {
21        let chunks = Layout::default()
22            .direction(Direction::Vertical)
23            .constraints([
24                Constraint::Length(4), // Header
25                Constraint::Min(10),   // Main content
26                Constraint::Length(3), // Error message
27                Constraint::Length(3), // Instructions
28            ])
29            .split(area);
30
31        // Header
32        let provider_config = state.registry.get(&provider_id);
33        let provider_name = provider_config
34            .map(|c| c.name.as_str())
35            .unwrap_or("Unknown Provider");
36        let supports_oauth = provider_config
37            .map(|c| {
38                c.auth_schemes
39                    .contains(&steer_grpc::proto::ProviderAuthScheme::AuthSchemeOauth2)
40            })
41            .unwrap_or(false);
42
43        let header = vec![
44            Line::from(""),
45            Line::from(Span::styled(
46                format!("Authenticate with {provider_name}"),
47                theme.style(Component::SetupHeader),
48            )),
49        ];
50
51        Paragraph::new(header)
52            .block(
53                Block::default()
54                    .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT)
55                    .border_style(theme.style(Component::SetupBorder)),
56            )
57            .alignment(Alignment::Center)
58            .render(chunks[0], buf);
59
60        // Main content
61        let mut content = vec![];
62
63        if let Some(oauth_state) = &state.oauth_state {
64            // OAuth flow in progress
65            content.push(Line::from(""));
66            content.push(Line::from(Span::styled(
67                "OAuth Authentication",
68                theme.style(Component::SetupHeader),
69            )));
70            content.push(Line::from(""));
71
72            if state.oauth_callback_input.is_empty() {
73                content.push(Line::from("Please visit this URL in your browser:"));
74                content.push(Line::from(""));
75                content.push(Line::from(Span::styled(
76                    &oauth_state.auth_url,
77                    theme.style(Component::SetupUrl),
78                )));
79                content.push(Line::from(""));
80                content.push(Line::from(
81                    "After authorizing, you'll be redirected to a page showing a code.",
82                ));
83                content.push(Line::from(
84                    "Copy the ENTIRE code (including the part after the #)",
85                ));
86                content.push(Line::from("and paste it below:"));
87                content.push(Line::from(""));
88                content.push(Line::from(vec![
89                    Span::styled("Code: ", theme.style(Component::SetupInputLabel)),
90                    Span::styled(
91                        if state.oauth_callback_input.is_empty() {
92                            "_"
93                        } else {
94                            &state.oauth_callback_input
95                        },
96                        theme.style(Component::SetupInputValue),
97                    ),
98                ]));
99            } else {
100                content.push(Line::from("Processing authorization code..."));
101                content.push(Line::from(""));
102                content.push(Line::from(vec![
103                    Span::styled("Code: ", theme.style(Component::SetupInputLabel)),
104                    Span::styled(
105                        &state.oauth_callback_input,
106                        theme.style(Component::SetupInputValue),
107                    ),
108                ]));
109            }
110        } else if supports_oauth
111            && provider_id == provider::anthropic()
112            && state.api_key_input.is_empty()
113            && state.oauth_state.is_none()
114            && state.auth_providers.get(&provider_id)
115                != Some(&crate::tui::state::AuthStatus::InProgress)
116        {
117            // Anthropic - show auth options
118            content.push(Line::from(""));
119            content.push(Line::from("Choose authentication method:"));
120            content.push(Line::from(""));
121            content.push(Line::from(vec![
122                Span::styled("1. ", theme.style(Component::SetupKeyBinding)),
123                Span::raw("OAuth Login "),
124                Span::styled(
125                    "(Recommended for Claude Pro users)",
126                    theme.style(Component::SetupHint),
127                ),
128            ]));
129            content.push(Line::from(vec![
130                Span::styled("2. ", theme.style(Component::SetupKeyBinding)),
131                Span::raw("API Key"),
132            ]));
133        } else {
134            // API key input
135            content.push(Line::from(""));
136            content.push(Line::from(format!("Enter your {provider_name} API key:")));
137            content.push(Line::from(""));
138
139            let masked_key = if state.api_key_input.is_empty() {
140                String::from("_")
141            } else {
142                "*".repeat(state.api_key_input.len())
143            };
144
145            content.push(Line::from(vec![
146                Span::styled("API Key: ", theme.style(Component::SetupInputLabel)),
147                Span::styled(masked_key, theme.style(Component::SetupInputValue)),
148            ]));
149
150            if provider_id == provider::anthropic() {
151                content.push(Line::from(""));
152                content.push(Line::from(Span::styled(
153                    "Tip: Get your API key from console.anthropic.com",
154                    theme.style(Component::SetupHint),
155                )));
156            }
157        }
158
159        Paragraph::new(content)
160            .block(
161                Block::default()
162                    .borders(Borders::LEFT | Borders::RIGHT)
163                    .border_style(theme.style(Component::SetupBorder)),
164            )
165            .alignment(Alignment::Left)
166            .wrap(Wrap { trim: true })
167            .render(chunks[1], buf);
168
169        // Error message
170        if let Some(error) = &state.error_message {
171            let error_text = vec![Line::from(Span::styled(
172                format!("Error: {error}"),
173                theme.style(Component::SetupErrorMessage),
174            ))];
175
176            Paragraph::new(error_text)
177                .block(
178                    Block::default()
179                        .borders(Borders::LEFT | Borders::RIGHT)
180                        .border_style(theme.style(Component::SetupBorder)),
181                )
182                .alignment(Alignment::Center)
183                .render(chunks[2], buf);
184        }
185
186        // Instructions
187        let instructions = if state.oauth_state.is_some() {
188            vec![Line::from(vec![
189                Span::styled("Esc", theme.style(Component::SetupKeyBinding)),
190                Span::raw(" to cancel"),
191            ])]
192        } else if supports_oauth
193            && provider_id == provider::anthropic()
194            && state.api_key_input.is_empty()
195        {
196            vec![Line::from(vec![
197                Span::raw("Press "),
198                Span::styled("1", theme.style(Component::SetupKeyBinding)),
199                Span::raw(" or "),
200                Span::styled("2", theme.style(Component::SetupKeyBinding)),
201                Span::raw(" to select, "),
202                Span::styled("Esc", theme.style(Component::SetupKeyBinding)),
203                Span::raw(" to go back"),
204            ])]
205        } else {
206            vec![Line::from(vec![
207                Span::raw("Type your API key, "),
208                Span::styled("Enter", theme.style(Component::SetupKeyBinding)),
209                Span::raw(" to submit, "),
210                Span::styled("Esc", theme.style(Component::SetupKeyBinding)),
211                Span::raw(" to go back"),
212            ])]
213        };
214
215        Paragraph::new(instructions)
216            .block(
217                Block::default()
218                    .borders(Borders::BOTTOM | Borders::LEFT | Borders::RIGHT)
219                    .border_style(theme.style(Component::SetupBorder)),
220            )
221            .alignment(Alignment::Center)
222            .render(chunks[3], buf);
223    }
224}