001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.oauth;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.BorderLayout;
007import java.awt.Component;
008import java.awt.Dimension;
009import java.awt.FlowLayout;
010import java.awt.Font;
011import java.awt.GridBagLayout;
012import java.awt.event.ActionEvent;
013import java.awt.event.ComponentAdapter;
014import java.awt.event.ComponentEvent;
015import java.awt.event.WindowAdapter;
016import java.awt.event.WindowEvent;
017import java.beans.PropertyChangeEvent;
018import java.beans.PropertyChangeListener;
019import java.lang.reflect.InvocationTargetException;
020import java.net.URL;
021import java.util.Objects;
022import java.util.concurrent.Executor;
023import java.util.concurrent.FutureTask;
024
025import javax.swing.AbstractAction;
026import javax.swing.BorderFactory;
027import javax.swing.JButton;
028import javax.swing.JDialog;
029import javax.swing.JPanel;
030import javax.swing.JScrollPane;
031import javax.swing.SwingUtilities;
032import javax.swing.UIManager;
033import javax.swing.text.html.HTMLEditorKit;
034
035import org.openstreetmap.josm.data.oauth.OAuthAccessTokenHolder;
036import org.openstreetmap.josm.data.oauth.OAuthParameters;
037import org.openstreetmap.josm.data.oauth.OAuthToken;
038import org.openstreetmap.josm.gui.MainApplication;
039import org.openstreetmap.josm.gui.help.ContextSensitiveHelpAction;
040import org.openstreetmap.josm.gui.help.HelpUtil;
041import org.openstreetmap.josm.gui.util.GuiHelper;
042import org.openstreetmap.josm.gui.util.WindowGeometry;
043import org.openstreetmap.josm.gui.widgets.HtmlPanel;
044import org.openstreetmap.josm.gui.widgets.JMultilineLabel;
045import org.openstreetmap.josm.spi.preferences.Config;
046import org.openstreetmap.josm.tools.GBC;
047import org.openstreetmap.josm.tools.ImageProvider;
048import org.openstreetmap.josm.tools.InputMapUtils;
049import org.openstreetmap.josm.tools.UserCancelException;
050import org.openstreetmap.josm.tools.Utils;
051
052/**
053 * This wizard walks the user to the necessary steps to retrieve an OAuth Access Token which
054 * allows JOSM to access the OSM API on the users behalf.
055 * @since 2746
056 */
057public class OAuthAuthorizationWizard extends JDialog {
058    private boolean canceled;
059    private final AuthorizationProcedure procedure;
060    private final String apiUrl;
061
062    private FullyAutomaticAuthorizationUI pnlFullyAutomaticAuthorisationUI;
063    private SemiAutomaticAuthorizationUI pnlSemiAutomaticAuthorisationUI;
064    private ManualAuthorizationUI pnlManualAuthorisationUI;
065    private JScrollPane spAuthorisationProcedureUI;
066    private final transient Executor executor;
067
068    /**
069     * Launches the wizard, {@link OAuthAccessTokenHolder#setAccessToken(OAuthToken) sets the token}
070     * and {@link OAuthAccessTokenHolder#setSaveToPreferences(boolean) saves to preferences}.
071     * @throws UserCancelException if user cancels the operation
072     */
073    public void showDialog() throws UserCancelException {
074        setVisible(true);
075        if (isCanceled()) {
076            throw new UserCancelException();
077        }
078        OAuthAccessTokenHolder holder = OAuthAccessTokenHolder.getInstance();
079        holder.setAccessToken(getAccessToken());
080        holder.setSaveToPreferences(isSaveAccessTokenToPreferences());
081    }
082
083    /**
084     * Builds the row with the action buttons
085     *
086     * @return panel with buttons
087     */
088    protected JPanel buildButtonRow() {
089        JPanel pnl = new JPanel(new FlowLayout(FlowLayout.CENTER));
090
091        AcceptAccessTokenAction actAcceptAccessToken = new AcceptAccessTokenAction();
092        pnlFullyAutomaticAuthorisationUI.addPropertyChangeListener(actAcceptAccessToken);
093        pnlSemiAutomaticAuthorisationUI.addPropertyChangeListener(actAcceptAccessToken);
094        pnlManualAuthorisationUI.addPropertyChangeListener(actAcceptAccessToken);
095
096        pnl.add(new JButton(actAcceptAccessToken));
097        pnl.add(new JButton(new CancelAction()));
098        pnl.add(new JButton(new ContextSensitiveHelpAction(HelpUtil.ht("/Dialog/OAuthAuthorisationWizard"))));
099
100        return pnl;
101    }
102
103    /**
104     * Builds the panel with general information in the header
105     *
106     * @return panel with information display
107     */
108    protected JPanel buildHeaderInfoPanel() {
109        JPanel pnl = new JPanel(new GridBagLayout());
110        pnl.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
111
112        // OAuth in a nutshell ...
113        HtmlPanel pnlMessage = new HtmlPanel();
114        pnlMessage.setText("<html><body>"
115                + tr("With OAuth you grant JOSM the right to upload map data and GPS tracks "
116                        + "on your behalf (<a href=\"{0}\">more info...</a>).", "https://wiki.openstreetmap.org/wiki/OAuth")
117                        + "</body></html>"
118        );
119        pnlMessage.enableClickableHyperlinks();
120        pnl.add(pnlMessage, GBC.eol().fill(GBC.HORIZONTAL));
121
122        // the authorisation procedure
123        JMultilineLabel lbl = new JMultilineLabel(AuthorizationProcedure.FULLY_AUTOMATIC.getDescription());
124        lbl.setFont(lbl.getFont().deriveFont(Font.PLAIN));
125        pnl.add(lbl, GBC.std());
126
127        if (!Config.getUrls().getDefaultOsmApiUrl().equals(apiUrl)) {
128            final HtmlPanel pnlWarning = new HtmlPanel();
129            final HTMLEditorKit kit = (HTMLEditorKit) pnlWarning.getEditorPane().getEditorKit();
130            kit.getStyleSheet().addRule(".warning-body {"
131                    + "background-color:rgb(253,255,221);padding: 10pt; "
132                    + "border-color:rgb(128,128,128);border-style: solid;border-width: 1px;}");
133            kit.getStyleSheet().addRule("ol {margin-left: 1cm}");
134            pnlWarning.setText("<html><body>"
135                    + "<p class=\"warning-body\">"
136                    + tr("<strong>Warning:</strong> Since you are using not the default OSM API, " +
137                    "make sure to set an OAuth consumer key and secret in the <i>Advanced OAuth parameters</i>.")
138                    + "</p>"
139                    + "</body></html>");
140            pnl.add(pnlWarning, GBC.eop().fill());
141        }
142
143        return pnl;
144    }
145
146    /**
147     * Refreshes the view of the authorisation panel, depending on the authorisation procedure
148     * currently selected
149     */
150    protected void refreshAuthorisationProcedurePanel() {
151        switch(procedure) {
152        case FULLY_AUTOMATIC:
153            spAuthorisationProcedureUI.getViewport().setView(pnlFullyAutomaticAuthorisationUI);
154            pnlFullyAutomaticAuthorisationUI.revalidate();
155            break;
156        case SEMI_AUTOMATIC:
157            spAuthorisationProcedureUI.getViewport().setView(pnlSemiAutomaticAuthorisationUI);
158            pnlSemiAutomaticAuthorisationUI.revalidate();
159            break;
160        case MANUALLY:
161            spAuthorisationProcedureUI.getViewport().setView(pnlManualAuthorisationUI);
162            pnlManualAuthorisationUI.revalidate();
163            break;
164        }
165        validate();
166        repaint();
167    }
168
169    /**
170     * builds the UI
171     */
172    protected final void build() {
173        getContentPane().setLayout(new BorderLayout());
174        getContentPane().add(buildHeaderInfoPanel(), BorderLayout.NORTH);
175
176        setTitle(tr("Get an Access Token for ''{0}''", apiUrl));
177        this.setMinimumSize(new Dimension(500, 300));
178
179        pnlFullyAutomaticAuthorisationUI = new FullyAutomaticAuthorizationUI(apiUrl, executor);
180        pnlSemiAutomaticAuthorisationUI = new SemiAutomaticAuthorizationUI(apiUrl, executor);
181        pnlManualAuthorisationUI = new ManualAuthorizationUI(apiUrl, executor);
182
183        spAuthorisationProcedureUI = GuiHelper.embedInVerticalScrollPane(new JPanel());
184        spAuthorisationProcedureUI.getVerticalScrollBar().addComponentListener(
185                new ComponentAdapter() {
186                    @Override
187                    public void componentShown(ComponentEvent e) {
188                        spAuthorisationProcedureUI.setBorder(UIManager.getBorder("ScrollPane.border"));
189                    }
190
191                    @Override
192                    public void componentHidden(ComponentEvent e) {
193                        spAuthorisationProcedureUI.setBorder(null);
194                    }
195                }
196        );
197        getContentPane().add(spAuthorisationProcedureUI, BorderLayout.CENTER);
198        getContentPane().add(buildButtonRow(), BorderLayout.SOUTH);
199
200        addWindowListener(new WindowEventHandler());
201        InputMapUtils.addEscapeAction(getRootPane(), new CancelAction());
202
203        refreshAuthorisationProcedurePanel();
204
205        HelpUtil.setHelpContext(getRootPane(), HelpUtil.ht("/Dialog/OAuthAuthorisationWizard"));
206    }
207
208    /**
209     * Creates the wizard.
210     *
211     * @param parent the component relative to which the dialog is displayed
212     * @param procedure the authorization procedure to use
213     * @param apiUrl the API URL. Must not be null.
214     * @param executor the executor used for running the HTTP requests for the authorization
215     * @throws IllegalArgumentException if apiUrl is null
216     */
217    public OAuthAuthorizationWizard(Component parent, AuthorizationProcedure procedure, String apiUrl, Executor executor) {
218        super(GuiHelper.getFrameForComponent(parent), ModalityType.DOCUMENT_MODAL);
219        this.procedure = Objects.requireNonNull(procedure, "procedure");
220        this.apiUrl = Objects.requireNonNull(apiUrl, "apiUrl");
221        this.executor = executor;
222        build();
223    }
224
225    /**
226     * Replies true if the dialog was canceled
227     *
228     * @return true if the dialog was canceled
229     */
230    public boolean isCanceled() {
231        return canceled;
232    }
233
234    protected AbstractAuthorizationUI getCurrentAuthorisationUI() {
235        switch(procedure) {
236        case FULLY_AUTOMATIC: return pnlFullyAutomaticAuthorisationUI;
237        case MANUALLY: return pnlManualAuthorisationUI;
238        case SEMI_AUTOMATIC: return pnlSemiAutomaticAuthorisationUI;
239        default: return null;
240        }
241    }
242
243    /**
244     * Replies the Access Token entered using the wizard
245     *
246     * @return the access token. May be null if the wizard was canceled.
247     */
248    public OAuthToken getAccessToken() {
249        return getCurrentAuthorisationUI().getAccessToken();
250    }
251
252    /**
253     * Replies the current OAuth parameters.
254     *
255     * @return the current OAuth parameters.
256     */
257    public OAuthParameters getOAuthParameters() {
258        return getCurrentAuthorisationUI().getOAuthParameters();
259    }
260
261    /**
262     * Replies true if the currently selected Access Token shall be saved to
263     * the preferences.
264     *
265     * @return true if the currently selected Access Token shall be saved to
266     * the preferences
267     */
268    public boolean isSaveAccessTokenToPreferences() {
269        return getCurrentAuthorisationUI().isSaveAccessTokenToPreferences();
270    }
271
272    /**
273     * Initializes the dialog with values from the preferences
274     *
275     */
276    public void initFromPreferences() {
277        pnlFullyAutomaticAuthorisationUI.initialize(apiUrl);
278        pnlSemiAutomaticAuthorisationUI.initialize(apiUrl);
279        pnlManualAuthorisationUI.initialize(apiUrl);
280    }
281
282    @Override
283    public void setVisible(boolean visible) {
284        if (visible) {
285            pack();
286            new WindowGeometry(
287                    getClass().getName() + ".geometry",
288                    WindowGeometry.centerInWindow(
289                            MainApplication.getMainFrame(),
290                            getPreferredSize()
291                    )
292            ).applySafe(this);
293            initFromPreferences();
294        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
295            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
296        }
297        super.setVisible(visible);
298    }
299
300    protected void setCanceled(boolean canceled) {
301        this.canceled = canceled;
302    }
303
304    /**
305     * Obtains an OAuth access token for the connection. Afterwards, the token is accessible via {@link OAuthAccessTokenHolder}.
306     * @param serverUrl the URL to OSM server
307     * @throws InterruptedException if we're interrupted while waiting for the event dispatching thread to finish OAuth authorization task
308     * @throws InvocationTargetException if an exception is thrown while running OAuth authorization task
309     * @since 12803
310     */
311    public static void obtainAccessToken(final URL serverUrl) throws InvocationTargetException, InterruptedException {
312        final Runnable authTask = new FutureTask<>(() -> {
313            // Concerning Utils.newDirectExecutor: Main worker cannot be used since this connection is already
314            // executed via main worker. The OAuth connections would block otherwise.
315            final OAuthAuthorizationWizard wizard = new OAuthAuthorizationWizard(
316                    MainApplication.getMainFrame(),
317                    AuthorizationProcedure.FULLY_AUTOMATIC,
318                    serverUrl.toExternalForm(), Utils.newDirectExecutor());
319            wizard.showDialog();
320            return wizard;
321        });
322        // exception handling differs from implementation at GuiHelper.runInEDTAndWait()
323        if (SwingUtilities.isEventDispatchThread()) {
324            authTask.run();
325        } else {
326            SwingUtilities.invokeAndWait(authTask);
327        }
328    }
329
330    class CancelAction extends AbstractAction {
331
332        /**
333         * Constructs a new {@code CancelAction}.
334         */
335        CancelAction() {
336            putValue(NAME, tr("Cancel"));
337            new ImageProvider("cancel").getResource().attachImageIcon(this);
338            putValue(SHORT_DESCRIPTION, tr("Close the dialog and cancel authorization"));
339        }
340
341        public void cancel() {
342            setCanceled(true);
343            setVisible(false);
344        }
345
346        @Override
347        public void actionPerformed(ActionEvent evt) {
348            cancel();
349        }
350    }
351
352    class AcceptAccessTokenAction extends AbstractAction implements PropertyChangeListener {
353
354        /**
355         * Constructs a new {@code AcceptAccessTokenAction}.
356         */
357        AcceptAccessTokenAction() {
358            putValue(NAME, tr("Accept Access Token"));
359            new ImageProvider("ok").getResource().attachImageIcon(this);
360            putValue(SHORT_DESCRIPTION, tr("Close the dialog and accept the Access Token"));
361            updateEnabledState(null);
362        }
363
364        @Override
365        public void actionPerformed(ActionEvent evt) {
366            setCanceled(false);
367            setVisible(false);
368        }
369
370        public final void updateEnabledState(OAuthToken token) {
371            setEnabled(token != null);
372        }
373
374        @Override
375        public void propertyChange(PropertyChangeEvent evt) {
376            if (!evt.getPropertyName().equals(AbstractAuthorizationUI.ACCESS_TOKEN_PROP))
377                return;
378            updateEnabledState((OAuthToken) evt.getNewValue());
379        }
380    }
381
382    class WindowEventHandler extends WindowAdapter {
383        @Override
384        public void windowClosing(WindowEvent e) {
385            new CancelAction().cancel();
386        }
387    }
388}